Stream new albums over SSE
This commit is contained in:
parent
1bbc4260de
commit
090b2b95bd
3 changed files with 319 additions and 2 deletions
|
|
@ -1,12 +1,25 @@
|
|||
<script lang="ts">
|
||||
import Album from '$components/Album.svelte'
|
||||
import type { Album as AlbumType } from '$lib/types/lastfm'
|
||||
import { albumStream } from '$lib/stores/album-stream'
|
||||
|
||||
interface RecentAlbumsProps {
|
||||
albums?: AlbumType[]
|
||||
}
|
||||
|
||||
let { albums = [] }: RecentAlbumsProps = $props()
|
||||
let { albums: initialAlbums = [] }: RecentAlbumsProps = $props()
|
||||
|
||||
// Use SSE stream for real-time updates, fallback to initial albums
|
||||
let albums = $state<AlbumType[]>(initialAlbums)
|
||||
|
||||
$effect(() => {
|
||||
const unsubscribe = albumStream.albums.subscribe((streamAlbums) => {
|
||||
if (streamAlbums.length > 0) {
|
||||
albums = streamAlbums
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
})
|
||||
|
||||
let hoveredAlbumId: string | null = $state(null)
|
||||
|
||||
|
|
|
|||
108
src/lib/stores/album-stream.ts
Normal file
108
src/lib/stores/album-stream.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||
import { browser } from '$app/environment'
|
||||
import type { Album } from '$lib/types/lastfm'
|
||||
|
||||
interface AlbumStreamState {
|
||||
connected: boolean
|
||||
albums: Album[]
|
||||
lastUpdate: Date | null
|
||||
}
|
||||
|
||||
function createAlbumStream() {
|
||||
const { subscribe, set, update } = writable<AlbumStreamState>({
|
||||
connected: false,
|
||||
albums: [],
|
||||
lastUpdate: null
|
||||
})
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null
|
||||
let reconnectAttempts = 0
|
||||
|
||||
function connect() {
|
||||
if (!browser || eventSource?.readyState === EventSource.OPEN) return
|
||||
|
||||
// Clean up existing connection
|
||||
disconnect()
|
||||
|
||||
eventSource = new EventSource('/api/lastfm/stream')
|
||||
|
||||
eventSource.addEventListener('connected', () => {
|
||||
console.log('Album stream connected')
|
||||
reconnectAttempts = 0
|
||||
update((state) => ({ ...state, connected: true }))
|
||||
})
|
||||
|
||||
eventSource.addEventListener('albums', (event) => {
|
||||
try {
|
||||
const albums: Album[] = JSON.parse(event.data)
|
||||
update((state) => ({
|
||||
...state,
|
||||
albums,
|
||||
lastUpdate: new Date()
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error parsing albums update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Heartbeat received, connection is healthy
|
||||
})
|
||||
|
||||
eventSource.addEventListener('error', (error) => {
|
||||
console.error('Album stream error:', error)
|
||||
update((state) => ({ ...state, connected: false }))
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
if (reconnectAttempts < 5) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectAttempts++
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('open', () => {
|
||||
update((state) => ({ ...state, connected: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
|
||||
update((state) => ({ ...state, connected: false }))
|
||||
}
|
||||
|
||||
// Auto-connect in browser
|
||||
if (browser) {
|
||||
connect()
|
||||
|
||||
// Reconnect on visibility change
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const currentState = get({ subscribe })
|
||||
if (document.visibilityState === 'visible' && !currentState.connected) {
|
||||
connect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
connect,
|
||||
disconnect,
|
||||
// Derived store for just the albums
|
||||
albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const albumStream = createAlbumStream()
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { LastClient } from '@musicorum/lastfm'
|
||||
import type { RequestHandler } from './$types'
|
||||
import type { Album } from '$lib/types/lastfm'
|
||||
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'
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||
async start(controller) {
|
||||
const client = new LastClient(LASTFM_API_KEY || '')
|
||||
let lastNowPlayingState: Map<string, { isPlaying: boolean; track?: string }> = new Map()
|
||||
let lastAlbumOrder: string[] = [] // Track album order changes
|
||||
let intervalId: NodeJS.Timeout | null = null
|
||||
let isClosed = false
|
||||
|
||||
|
|
@ -52,6 +54,40 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Fetch full album data
|
||||
const albums = await getRecentAlbums(client)
|
||||
|
||||
// Enrich albums with additional info
|
||||
const enrichedAlbums = await Promise.all(
|
||||
albums.map(async (album) => {
|
||||
try {
|
||||
const enriched = await enrichAlbumWithInfo(client, album)
|
||||
return await searchAppleMusicForAlbum(enriched)
|
||||
} catch (error) {
|
||||
console.error(`Error enriching album ${album.name}:`, error)
|
||||
return album
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Check if album order has changed
|
||||
const currentAlbumOrder = enrichedAlbums.map(a => `${a.artist.name}:${a.name}`)
|
||||
const albumOrderChanged = JSON.stringify(currentAlbumOrder) !== JSON.stringify(lastAlbumOrder)
|
||||
|
||||
if (albumOrderChanged) {
|
||||
lastAlbumOrder = currentAlbumOrder
|
||||
// Send full album update
|
||||
if (!isClosed) {
|
||||
try {
|
||||
const data = JSON.stringify(enrichedAlbums)
|
||||
controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`))
|
||||
} catch (e) {
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now playing updates
|
||||
const nowPlayingAlbums = await getNowPlayingAlbums(client)
|
||||
const updates: NowPlayingUpdate[] = []
|
||||
const currentAlbums = new Set<string>()
|
||||
|
|
@ -302,3 +338,163 @@ function checkWithTracks(
|
|||
|
||||
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<Album> {
|
||||
// Check cache for album info
|
||||
const cacheKey = `lastfm:albuminfo:${album.artist.name}:${album.name}`
|
||||
const cached = await redis.get(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
console.log(`Using cached album info for "${album.name}"`)
|
||||
const albumInfo = JSON.parse(cached)
|
||||
return {
|
||||
...album,
|
||||
url: albumInfo?.url || '',
|
||||
images: transformImages(albumInfo?.images || [])
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`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<Album> {
|
||||
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) {
|
||||
console.error(
|
||||
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
// Return album unchanged if Apple Music search fails
|
||||
return album
|
||||
}
|
||||
|
||||
async function getRecentAlbums(
|
||||
client: LastClient,
|
||||
limit: number = 4
|
||||
): Promise<Album[]> {
|
||||
// Check cache for recent tracks
|
||||
const cacheKey = `lastfm:recent:${USERNAME}`
|
||||
const cached = await redis.get(cacheKey)
|
||||
|
||||
let recentTracksResponse
|
||||
if (cached) {
|
||||
console.log('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 {
|
||||
console.log('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<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, {
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue