refactor: extract LastFM stream logic into separate utilities
- Create lastfmTransformers.ts for data transformations - Create nowPlayingDetector.ts for cleaner detection algorithms - Create albumEnricher.ts for album data enrichment - Create lastfmStreamManager.ts to orchestrate streaming logic This separates concerns and makes the code more testable and maintainable. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
64b5a8e73c
commit
a8feb173cb
4 changed files with 738 additions and 0 deletions
160
src/lib/utils/albumEnricher.ts
Normal file
160
src/lib/utils/albumEnricher.ts
Normal file
|
|
@ -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<Album> {
|
||||||
|
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<Album> {
|
||||||
|
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<Album> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {
|
||||||
|
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<any | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/lib/utils/lastfmStreamManager.ts
Normal file
281
src/lib/utils/lastfmStreamManager.ts
Normal file
|
|
@ -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<string, { isPlaying: boolean; track?: string }>
|
||||||
|
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<StreamUpdate> {
|
||||||
|
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<Album[]> {
|
||||||
|
// 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<string, Album>()
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
// 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<Album[]> {
|
||||||
|
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<NowPlayingUpdate[]> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/lib/utils/lastfmTransformers.ts
Normal file
69
src/lib/utils/lastfmTransformers.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/lib/utils/nowPlayingDetector.ts
Normal file
228
src/lib/utils/nowPlayingDetector.ts
Normal file
|
|
@ -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<any>
|
||||||
|
): Promise<Map<string, NowPlayingUpdate>> {
|
||||||
|
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<any>
|
||||||
|
): Promise<Map<string, NowPlayingUpdate>> {
|
||||||
|
const albums: Map<string, NowPlayingUpdate> = 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<string, NowPlayingUpdate>,
|
||||||
|
recentTracks: TrackPlayInfo[]
|
||||||
|
): Map<string, NowPlayingUpdate> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue