diff --git a/src/lib/utils/albumEnricher.ts b/src/lib/utils/albumEnricher.ts new file mode 100644 index 0000000..7775bc9 --- /dev/null +++ b/src/lib/utils/albumEnricher.ts @@ -0,0 +1,160 @@ +import type { Album } from '$lib/types/lastfm' +import type { LastClient } from '@musicorum/lastfm' +import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client' +import { transformImages, mergeAppleMusicData } from './lastfmTransformers' +import redis from '../../routes/api/redis-client' +import { logger } from '$lib/server/logger' + +export class AlbumEnricher { + private client: LastClient + private cacheTTL = { + albumInfo: 3600, // 1 hour for album info + appleMusicData: 86400, // 24 hours for Apple Music data + recentTracks: 30 // 30 seconds for recent tracks + } + + constructor(client: LastClient) { + this.client = client + } + + /** + * Enrich an album with additional information from Last.fm + */ + async enrichWithLastfmInfo(album: Album): Promise { + 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}"`) + try { + const albumInfo = await this.client.album.getInfo(album.name, album.artist.name) + + // Cache the result + await redis.set(cacheKey, JSON.stringify(albumInfo), 'EX', this.cacheTTL.albumInfo) + + return { + ...album, + url: albumInfo?.url || '', + images: transformImages(albumInfo?.images || []) + } + } catch (error) { + logger.error(`Failed to fetch album info for "${album.name}":`, error as Error, undefined, 'music') + return album + } + } + + /** + * Enrich an album with Apple Music data + */ + async enrichWithAppleMusic(album: Album): Promise { + try { + const cacheKey = `apple:album:${album.artist.name}:${album.name}` + const cached = await redis.get(cacheKey) + + if (cached) { + const cachedData = JSON.parse(cached) + return mergeAppleMusicData(album, cachedData) + } + + // Search Apple Music + const appleMusicAlbum = await findAlbum(album.artist.name, album.name) + + if (appleMusicAlbum) { + const transformedData = await transformAlbumData(appleMusicAlbum) + + // Cache the result + await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', this.cacheTTL.appleMusicData) + + return mergeAppleMusicData(album, 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 + } + + /** + * Fully enrich an album with both Last.fm and Apple Music data + */ + async enrichAlbum(album: Album): Promise { + try { + const withLastfmInfo = await this.enrichWithLastfmInfo(album) + const withAppleMusic = await this.enrichWithAppleMusic(withLastfmInfo) + return withAppleMusic + } catch (error) { + logger.error(`Error enriching album ${album.name}:`, error as Error, undefined, 'music') + return album + } + } + + /** + * Get Apple Music data for duration-based now playing detection + */ + async getAppleMusicDataForNowPlaying(artistName: string, albumName: string): Promise { + const cacheKey = `apple:album:${artistName}:${albumName}` + const cached = await redis.get(cacheKey) + + if (cached) { + return JSON.parse(cached) + } + + try { + const appleMusicAlbum = await findAlbum(artistName, albumName) + if (!appleMusicAlbum) return null + + const transformedData = await transformAlbumData(appleMusicAlbum) + await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', this.cacheTTL.appleMusicData) + + return transformedData + } catch (error) { + logger.error(`Error fetching Apple Music data for ${albumName}:`, error as Error, undefined, 'music') + return null + } + } + + /** + * Cache recent tracks from Last.fm + */ + async cacheRecentTracks(username: string, recentTracks: any): Promise { + const cacheKey = `lastfm:recent:${username}` + await redis.set(cacheKey, JSON.stringify(recentTracks), 'EX', this.cacheTTL.recentTracks) + } + + /** + * Get cached recent tracks + */ + async getCachedRecentTracks(username: string): Promise { + const cacheKey = `lastfm:recent:${username}` + const cached = await redis.get(cacheKey) + + if (cached) { + const data = JSON.parse(cached) + // Convert date strings back to Date objects + if (data.tracks) { + data.tracks = data.tracks.map((track: any) => ({ + ...track, + date: track.date ? new Date(track.date) : undefined + })) + } + return data + } + + return null + } +} \ No newline at end of file diff --git a/src/lib/utils/lastfmStreamManager.ts b/src/lib/utils/lastfmStreamManager.ts new file mode 100644 index 0000000..a6cf4b2 --- /dev/null +++ b/src/lib/utils/lastfmStreamManager.ts @@ -0,0 +1,281 @@ +import type { Album } from '$lib/types/lastfm' +import type { LastClient } from '@musicorum/lastfm' +import { NowPlayingDetector, type NowPlayingUpdate } from './nowPlayingDetector' +import { AlbumEnricher } from './albumEnricher' +import { trackToAlbum, getAlbumKey } from './lastfmTransformers' +import { logger } from '$lib/server/logger' + +export interface StreamState { + lastNowPlayingState: Map + lastAlbumOrder: string[] +} + +export interface StreamUpdate { + albums?: Album[] + nowPlayingUpdates?: NowPlayingUpdate[] +} + +export class LastfmStreamManager { + private client: LastClient + private username: string + private nowPlayingDetector: NowPlayingDetector + private albumEnricher: AlbumEnricher + private state: StreamState + + constructor(client: LastClient, username: string) { + this.client = client + this.username = username + this.nowPlayingDetector = new NowPlayingDetector() + this.albumEnricher = new AlbumEnricher(client) + this.state = { + lastNowPlayingState: new Map(), + lastAlbumOrder: [] + } + } + + /** + * Check for updates and return any changes + */ + async checkForUpdates(): Promise { + try { + // Fetch recent albums + const albums = await this.getRecentAlbums(4) + + // Process now playing status + await this.updateNowPlayingStatus(albums) + + // Enrich albums with additional data + const enrichedAlbums = await this.enrichAlbums(albums) + + // Ensure only one album is marked as now playing + this.ensureSingleNowPlaying(enrichedAlbums) + + // Check for changes + const update: StreamUpdate = {} + + // Check if album order or now playing status changed + if (this.hasAlbumsChanged(enrichedAlbums)) { + update.albums = enrichedAlbums + this.updateState(enrichedAlbums) + } + + // Check for now playing updates for non-recent albums + const nowPlayingUpdates = await this.getNowPlayingUpdatesForNonRecentAlbums(enrichedAlbums) + if (nowPlayingUpdates.length > 0) { + update.nowPlayingUpdates = nowPlayingUpdates + } + + return update + } catch (error) { + logger.error('Error checking for updates:', error as Error, undefined, 'music') + return {} + } + } + + /** + * Get recent albums from Last.fm + */ + private async getRecentAlbums(limit: number): Promise { + // Try cache first + const cached = await this.albumEnricher.getCachedRecentTracks(this.username) + + let recentTracksResponse + if (cached) { + logger.music('debug', 'Using cached Last.fm recent tracks for album stream') + recentTracksResponse = cached + } else { + logger.music('debug', 'Fetching fresh Last.fm recent tracks for album stream') + recentTracksResponse = await this.client.user.getRecentTracks(this.username, { + limit: 50, + extended: true + }) + // Cache the response + await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse) + } + + // Convert tracks to unique albums + 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, trackToAlbum(track, uniqueAlbums.size + 1)) + } else if (track.nowPlaying) { + // Update existing album if this track is now playing + const existingAlbum = uniqueAlbums.get(albumKey)! + uniqueAlbums.set(albumKey, { + ...existingAlbum, + isNowPlaying: true, + nowPlayingTrack: track.name + }) + } + } + + return Array.from(uniqueAlbums.values()) + } + + /** + * Update now playing status using the detector + */ + private async updateNowPlayingStatus(albums: Album[]): Promise { + // Get recent tracks for now playing detection + const cached = await this.albumEnricher.getCachedRecentTracks(this.username) + + let recentTracksResponse + if (cached) { + recentTracksResponse = cached + } else { + recentTracksResponse = await this.client.user.getRecentTracks(this.username, { + limit: 50, + extended: true + }) + await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse) + } + + // Process now playing detection + const nowPlayingMap = await this.nowPlayingDetector.processNowPlayingTracks( + recentTracksResponse, + (artistName, albumName) => this.albumEnricher.getAppleMusicDataForNowPlaying(artistName, albumName) + ) + + // Update albums with now playing status + for (const album of albums) { + const key = getAlbumKey(album.artist.name, album.name) + const nowPlayingInfo = nowPlayingMap.get(key) + + if (nowPlayingInfo) { + album.isNowPlaying = nowPlayingInfo.isNowPlaying + album.nowPlayingTrack = nowPlayingInfo.nowPlayingTrack + } + } + } + + /** + * Enrich albums with additional data + */ + private async enrichAlbums(albums: Album[]): Promise { + return Promise.all(albums.map(album => this.albumEnricher.enrichAlbum(album))) + } + + /** + * Ensure only one album is marked as now playing + */ + private ensureSingleNowPlaying(albums: Album[]): void { + const nowPlayingCount = albums.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` + ) + + // Keep only the first now playing album (albums are already sorted by recency) + let foundFirst = false + albums.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 albums have changed + */ + private hasAlbumsChanged(albums: Album[]): boolean { + // Check album order + const currentAlbumOrder = albums.map(a => getAlbumKey(a.artist.name, a.name)) + const albumOrderChanged = JSON.stringify(currentAlbumOrder) !== JSON.stringify(this.state.lastAlbumOrder) + + // Check now playing status + let nowPlayingChanged = false + for (const album of albums) { + const key = getAlbumKey(album.artist.name, album.name) + const lastState = this.state.lastNowPlayingState.get(key) + if ( + album.isNowPlaying !== (lastState?.isPlaying || false) || + (album.isNowPlaying && album.nowPlayingTrack !== lastState?.track) + ) { + nowPlayingChanged = true + break + } + } + + return albumOrderChanged || nowPlayingChanged + } + + /** + * Update internal state + */ + private updateState(albums: Album[]): void { + this.state.lastAlbumOrder = albums.map(a => getAlbumKey(a.artist.name, a.name)) + + for (const album of albums) { + const key = getAlbumKey(album.artist.name, album.name) + this.state.lastNowPlayingState.set(key, { + isPlaying: album.isNowPlaying || false, + track: album.nowPlayingTrack + }) + } + } + + /** + * Get now playing updates for albums not in the recent list + */ + private async getNowPlayingUpdatesForNonRecentAlbums(recentAlbums: Album[]): Promise { + const updates: NowPlayingUpdate[] = [] + + // Get all now playing albums + const cached = await this.albumEnricher.getCachedRecentTracks(this.username) + const recentTracksResponse = cached || await this.client.user.getRecentTracks(this.username, { + limit: 50, + extended: true + }) + + const nowPlayingMap = await this.nowPlayingDetector.processNowPlayingTracks( + recentTracksResponse, + (artistName, albumName) => this.albumEnricher.getAppleMusicDataForNowPlaying(artistName, albumName) + ) + + // Find albums that are now playing but not in recent albums + for (const [key, nowPlayingInfo] of nowPlayingMap) { + const isInRecentAlbums = recentAlbums.some( + a => getAlbumKey(a.artist.name, a.name) === key + ) + + if (!isInRecentAlbums) { + const lastState = this.state.lastNowPlayingState.get(key) + const wasPlaying = lastState?.isPlaying || false + const lastTrack = lastState?.track + + // Update if playing status changed OR if the track changed + if ( + nowPlayingInfo.isNowPlaying !== wasPlaying || + (nowPlayingInfo.isNowPlaying && nowPlayingInfo.nowPlayingTrack !== lastTrack) + ) { + updates.push(nowPlayingInfo) + logger.music( + 'debug', + `Now playing update for non-recent album ${nowPlayingInfo.albumName}: playing=${nowPlayingInfo.isNowPlaying}, track=${nowPlayingInfo.nowPlayingTrack}` + ) + } + + this.state.lastNowPlayingState.set(key, { + isPlaying: nowPlayingInfo.isNowPlaying, + track: nowPlayingInfo.nowPlayingTrack + }) + } + } + + return updates + } +} \ No newline at end of file diff --git a/src/lib/utils/lastfmTransformers.ts b/src/lib/utils/lastfmTransformers.ts new file mode 100644 index 0000000..e390c7c --- /dev/null +++ b/src/lib/utils/lastfmTransformers.ts @@ -0,0 +1,69 @@ +import type { Album, AlbumImages } from '$lib/types/lastfm' +import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common' + +/** + * Transform Last.fm image array into structured AlbumImages object + */ +export 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 + } + } + + // Set default to the largest available image + imageMap.default = imageMap.mega || imageMap.extralarge || imageMap.large || imageMap.medium || imageMap.small || '' + + return imageMap +} + +/** + * Create a unique key for an album + */ +export function getAlbumKey(artistName: string, albumName: string): string { + return `${artistName}:${albumName}` +} + +/** + * Transform track data into an Album object + */ +export function trackToAlbum(track: any, rank: number): Album { + return { + 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, + isNowPlaying: track.nowPlaying || false, + nowPlayingTrack: track.nowPlaying ? track.name : undefined + } +} + +/** + * Merge Apple Music data into an Album + */ +export function mergeAppleMusicData(album: Album, appleMusicData: any): Album { + return { + ...album, + images: { + ...album.images, + itunes: appleMusicData.highResArtwork || album.images.itunes + }, + appleMusicData + } +} \ No newline at end of file diff --git a/src/lib/utils/nowPlayingDetector.ts b/src/lib/utils/nowPlayingDetector.ts new file mode 100644 index 0000000..eb4c4ed --- /dev/null +++ b/src/lib/utils/nowPlayingDetector.ts @@ -0,0 +1,228 @@ +import type { Album } from '$lib/types/lastfm' +import { logger } from '$lib/server/logger' + +export interface TrackPlayInfo { + albumName: string + trackName: string + scrobbleTime: Date + durationMs?: number +} + +export interface NowPlayingUpdate { + albumName: string + artistName: string + isNowPlaying: boolean + nowPlayingTrack?: string +} + +export interface NowPlayingResult { + isNowPlaying: boolean + nowPlayingTrack?: string +} + +// Configuration constants +const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes to account for Last.fm scrobble delay +const TRACK_HISTORY_WINDOW = 60 * 60 * 1000 // Keep 1 hour of track history + +export class NowPlayingDetector { + private recentTracks: TrackPlayInfo[] = [] + + /** + * Update the recent tracks list with new track information + */ + updateRecentTracks(tracks: TrackPlayInfo[]) { + this.recentTracks = tracks + this.cleanupOldTracks() + } + + /** + * Clean up tracks older than the history window + */ + private cleanupOldTracks() { + const now = new Date() + const cutoffTime = new Date(now.getTime() - TRACK_HISTORY_WINDOW) + + this.recentTracks = this.recentTracks.filter( + track => track.scrobbleTime > cutoffTime + ) + } + + /** + * Check if an album is currently playing based on track duration + */ + checkAlbumNowPlaying( + albumName: string, + tracks?: Array<{ name: string; durationMs?: number }> + ): NowPlayingResult | null { + if (!tracks) return null + + const now = new Date() + + // Find the most recent track from this album + const albumTracks = this.recentTracks.filter( + track => track.albumName === albumName + ) + + if (albumTracks.length === 0) { + return { isNowPlaying: false } + } + + // Get the most recent track + const mostRecentTrack = albumTracks.reduce((latest, track) => + track.scrobbleTime > latest.scrobbleTime ? track : latest + ) + + // Find track duration from the tracks list + 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 } + } + + /** + * Process now playing data from Last.fm API response + */ + processNowPlayingTracks( + recentTracksResponse: any, + appleMusicDataLookup: (artistName: string, albumName: string) => Promise + ): Promise> { + return this.detectNowPlayingAlbums(recentTracksResponse.tracks, appleMusicDataLookup) + } + + /** + * Detect which albums are currently playing from a list of tracks + */ + private async detectNowPlayingAlbums( + tracks: any[], + appleMusicDataLookup: (artistName: string, albumName: string) => Promise + ): Promise> { + const albums: Map = new Map() + let hasOfficialNowPlaying = false + + // Update recent tracks list + const newRecentTracks: TrackPlayInfo[] = [] + + // Check if Last.fm reports any track as officially now playing + for (const track of tracks) { + if (track.nowPlaying) { + hasOfficialNowPlaying = true + break + } + } + + // Process all tracks + for (const track of tracks) { + // Store track play information + if (track.date) { + newRecentTracks.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) { + try { + const appleMusicData = await appleMusicDataLookup(album.artistName, album.albumName) + if (appleMusicData?.tracks) { + const result = this.checkAlbumNowPlaying(album.albumName, appleMusicData.tracks) + if (result?.isNowPlaying) { + album.isNowPlaying = true + album.nowPlayingTrack = result.nowPlayingTrack + } + } + } catch (error) { + logger.error(`Error checking duration for ${album.albumName}:`, error as Error, undefined, 'music') + } + } + + albums.set(albumKey, album) + } + } + + // Update recent tracks + this.updateRecentTracks(newRecentTracks) + + // Ensure only one album is marked as now playing + return this.ensureSingleNowPlaying(albums, newRecentTracks) + } + + /** + * Ensure only the most recent album is marked as now playing + */ + private ensureSingleNowPlaying( + albums: Map, + recentTracks: TrackPlayInfo[] + ): Map { + const nowPlayingAlbums = Array.from(albums.values()).filter(a => a.isNowPlaying) + + if (nowPlayingAlbums.length <= 1) { + return albums + } + + 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) { + 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 albums + } +} \ No newline at end of file