Realtime now playing

This commit is contained in:
Justin Edmund 2025-06-13 22:18:05 -04:00
parent 199abb294f
commit 6a0f1d7d3f
5 changed files with 426 additions and 7 deletions

View file

@ -2,6 +2,7 @@
import { spring } from 'svelte/motion'
import type { Album } from '$lib/types/lastfm'
import { audioPreview } from '$lib/stores/audio-preview'
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import NowPlaying from './NowPlaying.svelte'
interface AlbumProps {
@ -81,16 +82,24 @@
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
// Debug log
// Subscribe to real-time now playing updates
let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null)
$effect(() => {
if (album) {
console.log(`Album ${album.name}:`, {
hasAppleMusicData: !!album.appleMusicData,
previewUrl: album.appleMusicData?.previewUrl,
hasPreview
const unsubscribe = nowPlayingStream.isAlbumPlaying.subscribe(checkAlbum => {
const status = checkAlbum(album.artist.name, album.name)
if (status !== null) {
realtimeNowPlaying = status
}
})
return unsubscribe
}
})
// Combine initial state with real-time updates
const isNowPlaying = $derived(realtimeNowPlaying?.isNowPlaying ?? album?.isNowPlaying ?? false)
const nowPlayingTrack = $derived(realtimeNowPlaying?.nowPlayingTrack ?? album?.nowPlayingTrack)
</script>
<div class="album">
@ -110,8 +119,8 @@
style="transform: scale({$scale})"
loading="lazy"
/>
{#if album.isNowPlaying}
<NowPlaying trackName={album.nowPlayingTrack !== album.name ? album.nowPlayingTrack : undefined} />
{#if isNowPlaying}
<NowPlaying trackName={nowPlayingTrack !== album.name ? nowPlayingTrack : undefined} />
{/if}
{#if hasPreview && isHovering}
<button

View file

@ -0,0 +1,78 @@
<script lang="ts">
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
let isConnected = $state(false)
$effect(() => {
const unsubscribe = nowPlayingStream.subscribe(state => {
isConnected = state.connected
})
return unsubscribe
})
</script>
{#if isConnected}
<div class="stream-status connected" title="Live updates active">
<span class="dot"></span>
</div>
{/if}
<style lang="scss">
.stream-status {
position: fixed;
bottom: $unit * 2;
right: $unit * 2;
display: flex;
align-items: center;
gap: $unit-half;
padding: $unit-half $unit;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: $unit * 2;
font-size: $font-size-small;
z-index: 1000;
animation: fadeIn 0.3s ease-out;
&.connected {
.dot {
background: #4caf50;
animation: pulse 2s ease-in-out infinite;
}
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(0.9);
}
}
@include breakpoint('phone') {
.stream-status {
bottom: $unit;
right: $unit;
}
}
</style>

View file

@ -0,0 +1,133 @@
import { writable, derived, get, type Readable } from 'svelte/store'
import { browser } from '$app/environment'
interface NowPlayingUpdate {
albumName: string
artistName: string
isNowPlaying: boolean
nowPlayingTrack?: string
}
interface NowPlayingState {
connected: boolean
updates: Map<string, NowPlayingUpdate>
lastUpdate: Date | null
}
function createNowPlayingStream() {
const { subscribe, set, update } = writable<NowPlayingState>({
connected: false,
updates: new Map(),
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('Now Playing stream connected')
reconnectAttempts = 0
update(state => ({ ...state, connected: true }))
})
eventSource.addEventListener('nowplaying', (event) => {
try {
const updates: NowPlayingUpdate[] = JSON.parse(event.data)
update(state => {
const newUpdates = new Map(state.updates)
for (const album of updates) {
const key = `${album.artistName}:${album.albumName}`
newUpdates.set(key, album)
}
return {
...state,
updates: newUpdates,
lastUpdate: new Date()
}
})
} catch (error) {
console.error('Error parsing now playing update:', error)
}
})
eventSource.addEventListener('heartbeat', () => {
// Heartbeat received, connection is healthy
})
eventSource.addEventListener('error', (error) => {
console.error('Now Playing 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,
// Helper to check if a specific album is now playing
isAlbumPlaying: derived(
{ subscribe },
($state) => (artistName: string, albumName: string) => {
const key = `${artistName}:${albumName}`
const update = $state.updates.get(key)
return update ? {
isNowPlaying: update.isNowPlaying,
nowPlayingTrack: update.nowPlayingTrack
} : null
}
) as Readable<(artistName: string, albumName: string) => { isNowPlaying: boolean; nowPlayingTrack?: string } | null>
}
}
export const nowPlayingStream = createNowPlayingStream()

View file

@ -4,6 +4,7 @@
import MentionList from '$components/MentionList.svelte'
import Page from '$components/Page.svelte'
import RecentAlbums from '$components/RecentAlbums.svelte'
import StreamStatus from '$components/StreamStatus.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
@ -101,6 +102,8 @@
{/if}
</section> -->
</Page>
<StreamStatus />
</section>
<style lang="scss">

View file

@ -0,0 +1,196 @@
import { LastClient } from '@musicorum/lastfm'
import type { RequestHandler } from './$types'
import type { Album } from '$lib/types/lastfm'
import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client'
import redis from '../../redis-client'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
const USERNAME = 'jedmund'
const UPDATE_INTERVAL = 30000 // 30 seconds
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<string, boolean> = new Map()
// Send initial connection message
controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n'))
const checkForUpdates = async () => {
try {
const nowPlayingAlbums = await getNowPlayingAlbums(client)
const updates: NowPlayingUpdate[] = []
// Check for changes
for (const album of nowPlayingAlbums) {
const key = `${album.artistName}:${album.albumName}`
const wasPlaying = lastNowPlayingState.get(key) || false
if (album.isNowPlaying !== wasPlaying) {
updates.push(album)
lastNowPlayingState.set(key, album.isNowPlaying)
}
}
// Send updates if any
if (updates.length > 0) {
const data = JSON.stringify(updates)
controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`))
}
// Send heartbeat to keep connection alive
controller.enqueue(encoder.encode('event: heartbeat\ndata: {}\n\n'))
} catch (error) {
console.error('Error checking for updates:', error)
}
}
// Initial check
await checkForUpdates()
// Set up interval
const intervalId = setInterval(checkForUpdates, UPDATE_INTERVAL)
// Handle client disconnect
request.signal.addEventListener('abort', () => {
clearInterval(intervalId)
controller.close()
})
},
cancel() {
// Cleanup when stream is cancelled
console.log('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<NowPlayingUpdate[]> {
const recentTracksResponse = await client.user.getRecentTracks(USERNAME, { limit: 50, extended: true })
const albums: Map<string, NowPlayingUpdate> = new Map()
// Clear old tracks and collect new track play information
recentTracks = []
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
}
// If not marked as now playing by Last.fm, check with duration-based detection
if (!album.isNowPlaying) {
const updatedStatus = await checkNowPlayingWithDuration(album.albumName, album.artistName)
if (updatedStatus) {
album.isNowPlaying = updatedStatus.isNowPlaying
album.nowPlayingTrack = updatedStatus.nowPlayingTrack
}
}
albums.set(albumKey, album)
}
}
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) {
console.error(`Error checking duration for ${albumName}:`, error)
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
for (const trackInfo of recentTracks) {
if (trackInfo.albumName !== albumName) continue
const trackData = tracks.find(t =>
t.name.toLowerCase() === trackInfo.trackName.toLowerCase()
)
if (trackData?.durationMs) {
const trackEndTime = new Date(trackInfo.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG)
if (now < trackEndTime) {
return {
isNowPlaying: true,
nowPlayingTrack: trackInfo.trackName
}
}
}
}
return { isNowPlaying: false }
}