refactor: consolidate now playing detection into single music stream store
- Merge albumStream and nowPlayingStream into unified musicStream store - Simplify confidence scoring to binary detection (playing/not playing) - Create single source of truth for music state across components - Fix synchronization issues between header and album indicators - Make Album spring animation more subtle (stiffness: 150, damping: 25) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4d24be2457
commit
3d7eb6e985
9 changed files with 455 additions and 115 deletions
|
|
@ -2,10 +2,9 @@
|
||||||
import { Spring } from 'svelte/motion'
|
import { Spring } from 'svelte/motion'
|
||||||
import type { Album } from '$lib/types/lastfm'
|
import type { Album } from '$lib/types/lastfm'
|
||||||
import { audioPreview } from '$lib/stores/audio-preview'
|
import { audioPreview } from '$lib/stores/audio-preview'
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
|
||||||
import NowPlaying from './NowPlaying.svelte'
|
import NowPlaying from './NowPlaying.svelte'
|
||||||
import PlayIcon from '$icons/play.svg'
|
import PlayIcon from '$icons/play.svg?component'
|
||||||
import PauseIcon from '$icons/pause.svg'
|
import PauseIcon from '$icons/pause.svg?component'
|
||||||
|
|
||||||
interface AlbumProps {
|
interface AlbumProps {
|
||||||
album?: Album
|
album?: Album
|
||||||
|
|
@ -32,8 +31,8 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const scale = new Spring(1, {
|
const scale = new Spring(1, {
|
||||||
stiffness: 0.2,
|
stiffness: 0.3,
|
||||||
damping: 0.12
|
damping: 0.25
|
||||||
})
|
})
|
||||||
|
|
||||||
// Determine if this album should shrink
|
// Determine if this album should shrink
|
||||||
|
|
@ -41,9 +40,9 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isHovering) {
|
if (isHovering) {
|
||||||
scale.target = 1.1
|
scale.target = 1.05
|
||||||
} else if (shouldShrink) {
|
} else if (shouldShrink) {
|
||||||
scale.target = 0.95
|
scale.target = 0.97
|
||||||
} else {
|
} else {
|
||||||
scale.target = 1
|
scale.target = 1
|
||||||
}
|
}
|
||||||
|
|
@ -99,32 +98,17 @@
|
||||||
|
|
||||||
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
||||||
|
|
||||||
// Subscribe to real-time now playing updates
|
// Use the album's isNowPlaying status directly - single source of truth
|
||||||
let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null)
|
const isNowPlaying = $derived(album?.isNowPlaying ?? false)
|
||||||
|
const nowPlayingTrack = $derived(album?.nowPlayingTrack)
|
||||||
$effect(() => {
|
|
||||||
if (album) {
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album && isNowPlaying) {
|
if (album && (isNowPlaying || album.isNowPlaying)) {
|
||||||
console.log(`Album "${album.name}" is now playing:`, {
|
console.log(`🎵 Album component "${album.name}":`, {
|
||||||
fromRealtime: realtimeNowPlaying?.isNowPlaying,
|
isNowPlaying,
|
||||||
fromAlbum: album?.isNowPlaying,
|
nowPlayingTrack,
|
||||||
track: nowPlayingTrack
|
albumData: album
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -165,9 +149,9 @@
|
||||||
class:playing={isPlaying}
|
class:playing={isPlaying}
|
||||||
>
|
>
|
||||||
{#if isPlaying}
|
{#if isPlaying}
|
||||||
<svelte:component this={PauseIcon} />
|
<PauseIcon />
|
||||||
{:else}
|
{:else}
|
||||||
<svelte:component this={PlayIcon} />
|
<PlayIcon />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { Spring } from 'svelte/motion'
|
import { Spring } from 'svelte/motion'
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
import { musicStream } from '$lib/stores/music-stream'
|
||||||
import { albumStream } from '$lib/stores/album-stream'
|
|
||||||
import AvatarSVG from './AvatarSVG.svelte'
|
import AvatarSVG from './AvatarSVG.svelte'
|
||||||
import AvatarHeadphones from './AvatarHeadphones.svelte'
|
import AvatarHeadphones from './AvatarHeadphones.svelte'
|
||||||
import { get } from 'svelte/store'
|
|
||||||
|
|
||||||
// Props for testing/forcing states
|
// Props for testing/forcing states
|
||||||
let { forcePlayingMusic = false } = $props()
|
let { forcePlayingMusic = false } = $props()
|
||||||
|
|
@ -14,9 +12,6 @@
|
||||||
let isBlinking = $state(false)
|
let isBlinking = $state(false)
|
||||||
let isPlayingMusic = $state(forcePlayingMusic)
|
let isPlayingMusic = $state(forcePlayingMusic)
|
||||||
|
|
||||||
// Track store subscriptions for debugging
|
|
||||||
let nowPlayingStoreState = $state(null)
|
|
||||||
let albumStoreState = $state(null)
|
|
||||||
|
|
||||||
const scale = new Spring(1, {
|
const scale = new Spring(1, {
|
||||||
stiffness: 0.1,
|
stiffness: 0.1,
|
||||||
|
|
@ -66,56 +61,25 @@
|
||||||
}
|
}
|
||||||
}, 4000)
|
}, 4000)
|
||||||
|
|
||||||
// Subscribe to now playing updates from both sources
|
// Subscribe to music stream - single source of truth
|
||||||
const unsubscribeNowPlaying = nowPlayingStream.subscribe((state) => {
|
const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => {
|
||||||
nowPlayingStoreState = state
|
|
||||||
// Check if any album is currently playing, unless forced
|
|
||||||
if (!forcePlayingMusic) {
|
if (!forcePlayingMusic) {
|
||||||
const nowPlayingFromStream = Array.from(state.updates.values()).some(
|
isPlayingMusic = !!nowPlaying
|
||||||
(update) => update.isNowPlaying
|
if (nowPlaying) {
|
||||||
)
|
console.log('Avatar - music playing:', {
|
||||||
console.log('Avatar - nowPlayingStream update:', {
|
artist: nowPlaying.album.artist.name,
|
||||||
updatesCount: state.updates.size,
|
album: nowPlaying.album.name,
|
||||||
hasNowPlaying: nowPlayingFromStream
|
track: nowPlaying.track
|
||||||
})
|
})
|
||||||
// Don't set to false if we haven't received album data yet
|
|
||||||
if (nowPlayingFromStream || albumStoreState !== null) {
|
|
||||||
isPlayingMusic =
|
|
||||||
nowPlayingFromStream || (albumStoreState?.some((album) => album.isNowPlaying) ?? false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Also check the album stream
|
|
||||||
const unsubscribeAlbums = albumStream.subscribe((state) => {
|
|
||||||
albumStoreState = state.albums
|
|
||||||
if (!forcePlayingMusic) {
|
|
||||||
const hasNowPlaying = state.albums.some((album) => album.isNowPlaying)
|
|
||||||
|
|
||||||
// Get the current state of nowPlayingStream
|
|
||||||
const nowPlayingState = nowPlayingStoreState || get(nowPlayingStream)
|
|
||||||
const nowPlayingFromStream = Array.from(nowPlayingState.updates.values()).some(
|
|
||||||
(update) => update.isNowPlaying
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Avatar - albumStream update:', {
|
|
||||||
albumsCount: state.albums.length,
|
|
||||||
hasNowPlayingInAlbums: hasNowPlaying,
|
|
||||||
hasNowPlayingInStream: nowPlayingFromStream,
|
|
||||||
albums: state.albums.map((a) => ({ name: a.name, isNowPlaying: a.isNowPlaying }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update isPlayingMusic based on whether any album is now playing from either source
|
|
||||||
isPlayingMusic = hasNowPlaying || nowPlayingFromStream
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (blinkInterval) {
|
if (blinkInterval) {
|
||||||
clearInterval(blinkInterval)
|
clearInterval(blinkInterval)
|
||||||
}
|
}
|
||||||
unsubscribeNowPlaying()
|
unsubscribe()
|
||||||
unsubscribeAlbums()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
import SegmentedController from './SegmentedController.svelte'
|
import SegmentedController from './SegmentedController.svelte'
|
||||||
import NavDropdown from './NavDropdown.svelte'
|
import NavDropdown from './NavDropdown.svelte'
|
||||||
import NowPlayingBar from './NowPlayingBar.svelte'
|
import NowPlayingBar from './NowPlayingBar.svelte'
|
||||||
import { albumStream } from '$lib/stores/album-stream'
|
import { musicStream } from '$lib/stores/music-stream'
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
|
||||||
import type { Album } from '$lib/types/lastfm'
|
import type { Album } from '$lib/types/lastfm'
|
||||||
|
|
||||||
let scrollY = $state(0)
|
let scrollY = $state(0)
|
||||||
|
|
@ -18,40 +17,19 @@
|
||||||
let currentlyPlayingAlbum = $state<Album | null>(null)
|
let currentlyPlayingAlbum = $state<Album | null>(null)
|
||||||
let isPlayingMusic = $state(false)
|
let isPlayingMusic = $state(false)
|
||||||
|
|
||||||
// Subscribe to album updates
|
// Subscribe to music stream updates - single source of truth
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const unsubscribe = albumStream.subscribe((state) => {
|
const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => {
|
||||||
const nowPlaying = state.albums.find((album) => album.isNowPlaying)
|
currentlyPlayingAlbum = nowPlaying?.album || null
|
||||||
currentlyPlayingAlbum = nowPlaying || null
|
|
||||||
isPlayingMusic = !!nowPlaying
|
isPlayingMusic = !!nowPlaying
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (nowPlaying) {
|
console.log('🎧 Header now playing update:', {
|
||||||
console.log('Header: Now playing detected:', {
|
hasNowPlaying: !!nowPlaying,
|
||||||
artist: nowPlaying.artist.name,
|
album: nowPlaying?.album.name,
|
||||||
album: nowPlaying.name,
|
artist: nowPlaying?.album.artist.name,
|
||||||
track: nowPlaying.nowPlayingTrack
|
track: nowPlaying?.track
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return unsubscribe
|
|
||||||
})
|
|
||||||
|
|
||||||
// Also check now playing stream for updates
|
|
||||||
$effect(() => {
|
|
||||||
const unsubscribe = nowPlayingStream.subscribe((state) => {
|
|
||||||
const hasNowPlaying = Array.from(state.updates.values()).some((update) => update.isNowPlaying)
|
|
||||||
console.log('Header: nowPlayingStream update:', {
|
|
||||||
hasNowPlaying,
|
|
||||||
updatesCount: state.updates.size
|
|
||||||
})
|
})
|
||||||
// Only clear if we explicitly know music stopped
|
|
||||||
if (!hasNowPlaying && currentlyPlayingAlbum && state.updates.size > 0) {
|
|
||||||
// Music stopped
|
|
||||||
currentlyPlayingAlbum = null
|
|
||||||
isPlayingMusic = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Album from '$components/Album.svelte'
|
import Album from '$components/Album.svelte'
|
||||||
import type { Album as AlbumType } from '$lib/types/lastfm'
|
import type { Album as AlbumType } from '$lib/types/lastfm'
|
||||||
import { albumStream } from '$lib/stores/album-stream'
|
import { musicStream } from '$lib/stores/music-stream'
|
||||||
|
|
||||||
interface RecentAlbumsProps {
|
interface RecentAlbumsProps {
|
||||||
albums?: AlbumType[]
|
albums?: AlbumType[]
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
let albums = $state<AlbumType[]>(initialAlbums)
|
let albums = $state<AlbumType[]>(initialAlbums)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const unsubscribe = albumStream.albums.subscribe((streamAlbums) => {
|
const unsubscribe = musicStream.albums.subscribe((streamAlbums) => {
|
||||||
if (streamAlbums.length > 0) {
|
if (streamAlbums.length > 0) {
|
||||||
albums = streamAlbums
|
albums = streamAlbums
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
import { musicStream } from '$lib/stores/music-stream'
|
||||||
|
|
||||||
let isConnected = $state(false)
|
let isConnected = $state(false)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const unsubscribe = nowPlayingStream.subscribe((state) => {
|
const unsubscribe = musicStream.subscribe((state) => {
|
||||||
isConnected = state.connected
|
isConnected = state.connected
|
||||||
})
|
})
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
|
|
|
||||||
156
src/lib/stores/music-stream.ts
Normal file
156
src/lib/stores/music-stream.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||||
|
import { browser } from '$app/environment'
|
||||||
|
import type { Album } from '$lib/types/lastfm'
|
||||||
|
|
||||||
|
interface MusicStreamState {
|
||||||
|
connected: boolean
|
||||||
|
albums: Album[]
|
||||||
|
lastUpdate: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMusicStream() {
|
||||||
|
const { subscribe, set, update } = writable<MusicStreamState>({
|
||||||
|
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
|
||||||
|
|
||||||
|
// Don't connect in Storybook or admin
|
||||||
|
if (typeof window !== 'undefined' && window.parent !== window) {
|
||||||
|
console.log('Music stream disabled in Storybook')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up existing connection
|
||||||
|
disconnect()
|
||||||
|
|
||||||
|
eventSource = new EventSource('/api/lastfm/stream')
|
||||||
|
|
||||||
|
eventSource.addEventListener('connected', () => {
|
||||||
|
console.log('Music stream connected')
|
||||||
|
reconnectAttempts = 0
|
||||||
|
update((state) => ({ ...state, connected: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('albums', (event) => {
|
||||||
|
try {
|
||||||
|
const albums: Album[] = JSON.parse(event.data)
|
||||||
|
const nowPlayingAlbum = albums.find((a) => a.isNowPlaying)
|
||||||
|
const updateTime = new Date()
|
||||||
|
|
||||||
|
console.log('🎵 Music stream update at', updateTime.toLocaleTimeString(), {
|
||||||
|
totalAlbums: albums.length,
|
||||||
|
nowPlaying: nowPlayingAlbum
|
||||||
|
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
|
||||||
|
: 'none',
|
||||||
|
albums: albums.map(a => ({
|
||||||
|
name: a.name,
|
||||||
|
artist: a.artist.name,
|
||||||
|
isNowPlaying: a.isNowPlaying,
|
||||||
|
nowPlayingTrack: a.nowPlayingTrack
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
albums,
|
||||||
|
lastUpdate: updateTime
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing albums:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('heartbeat', (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
console.log('💓 Heartbeat at', new Date(data.timestamp).toLocaleTimeString(), {
|
||||||
|
interval: data.interval,
|
||||||
|
hasUpdates: data.hasUpdates
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update lastUpdate time even on heartbeat to keep countdown in sync
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
lastUpdate: new Date(data.timestamp)
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
// Old heartbeat format, ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (error) => {
|
||||||
|
console.error('Music stream error:', error)
|
||||||
|
update((state) => ({ ...state, connected: false }))
|
||||||
|
|
||||||
|
// 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 && !window.location.pathname.startsWith('/admin')) {
|
||||||
|
connect()
|
||||||
|
|
||||||
|
// Reconnect on visibility change
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
const currentState = get({ subscribe })
|
||||||
|
if (
|
||||||
|
document.visibilityState === 'visible' &&
|
||||||
|
!currentState.connected &&
|
||||||
|
!window.location.pathname.startsWith('/admin')
|
||||||
|
) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
// Derived store for albums
|
||||||
|
albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>,
|
||||||
|
// Helper to check if any album is playing
|
||||||
|
nowPlaying: derived({ subscribe }, ($state) => {
|
||||||
|
const playing = $state.albums.find(a => a.isNowPlaying)
|
||||||
|
return playing ? {
|
||||||
|
album: playing,
|
||||||
|
track: playing.nowPlayingTrack
|
||||||
|
} : null
|
||||||
|
}) as Readable<{ album: Album; track?: string } | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const musicStream = createMusicStream()
|
||||||
107
src/lib/utils/simpleLastfmStreamManager.ts
Normal file
107
src/lib/utils/simpleLastfmStreamManager.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import type { Album } from '$lib/types/lastfm'
|
||||||
|
import type { LastClient } from '@musicorum/lastfm'
|
||||||
|
import { SimpleNowPlayingDetector } from './simpleNowPlayingDetector'
|
||||||
|
import { AlbumEnricher } from './albumEnricher'
|
||||||
|
import { trackToAlbum } from './lastfmTransformers'
|
||||||
|
import { logger } from '$lib/server/logger'
|
||||||
|
|
||||||
|
export interface StreamUpdate {
|
||||||
|
albums?: Album[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SimpleLastfmStreamManager {
|
||||||
|
private client: LastClient
|
||||||
|
private username: string
|
||||||
|
private detector: SimpleNowPlayingDetector
|
||||||
|
private albumEnricher: AlbumEnricher
|
||||||
|
private lastAlbumState: string = ''
|
||||||
|
|
||||||
|
constructor(client: LastClient, username: string) {
|
||||||
|
this.client = client
|
||||||
|
this.username = username
|
||||||
|
this.detector = new SimpleNowPlayingDetector()
|
||||||
|
this.albumEnricher = new AlbumEnricher(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for updates and return any changes
|
||||||
|
*/
|
||||||
|
async checkForUpdates(): Promise<StreamUpdate> {
|
||||||
|
try {
|
||||||
|
// Fetch fresh data from Last.fm
|
||||||
|
logger.music('debug', '🔄 Fetching fresh tracks from Last.fm...')
|
||||||
|
const recentTracksResponse = await this.client.user.getRecentTracks(this.username, {
|
||||||
|
limit: 50,
|
||||||
|
extended: true
|
||||||
|
})
|
||||||
|
logger.music('debug', `📊 Got ${recentTracksResponse.tracks?.length || 0} tracks from Last.fm`)
|
||||||
|
|
||||||
|
// Cache for other uses but always use fresh for now playing
|
||||||
|
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
|
||||||
|
|
||||||
|
// Get recent albums (top 4)
|
||||||
|
const albums = await this.getRecentAlbums(4, recentTracksResponse)
|
||||||
|
|
||||||
|
// Debug the response structure
|
||||||
|
logger.music('debug', `📊 Response structure check:`, {
|
||||||
|
hasTracksProp: !!recentTracksResponse.tracks,
|
||||||
|
trackCount: recentTracksResponse.tracks?.length || 0,
|
||||||
|
firstTrack: recentTracksResponse.tracks?.[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process now playing status
|
||||||
|
const albumsWithNowPlaying = await this.detector.processAlbums(
|
||||||
|
albums,
|
||||||
|
recentTracksResponse.tracks,
|
||||||
|
(artistName, albumName) =>
|
||||||
|
this.albumEnricher.getAppleMusicDataForNowPlaying(artistName, albumName)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enrich albums with additional data
|
||||||
|
const enrichedAlbums = await Promise.all(
|
||||||
|
albumsWithNowPlaying.map((album) => this.albumEnricher.enrichAlbum(album))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if anything changed
|
||||||
|
const currentState = JSON.stringify(
|
||||||
|
enrichedAlbums.map(a => ({
|
||||||
|
key: `${a.artist.name}:${a.name}`,
|
||||||
|
isNowPlaying: a.isNowPlaying,
|
||||||
|
track: a.nowPlayingTrack
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentState !== this.lastAlbumState) {
|
||||||
|
this.lastAlbumState = currentState
|
||||||
|
logger.music('debug', '📤 Albums changed, sending update')
|
||||||
|
logger.music('debug', `Current state: ${currentState}`)
|
||||||
|
return { albums: enrichedAlbums }
|
||||||
|
} else {
|
||||||
|
logger.music('debug', '🔄 No changes detected, skipping update')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking for updates:', error as Error, undefined, 'music')
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent albums from Last.fm tracks
|
||||||
|
*/
|
||||||
|
private async getRecentAlbums(limit: number, recentTracksResponse: any): Promise<Album[]> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(uniqueAlbums.values())
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/lib/utils/simpleNowPlayingDetector.ts
Normal file
147
src/lib/utils/simpleNowPlayingDetector.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import type { Album } from '$lib/types/lastfm'
|
||||||
|
import { logger } from '$lib/server/logger'
|
||||||
|
|
||||||
|
// Simple buffer time for tracks that might have paused/buffered
|
||||||
|
const BUFFER_TIME_MS = 30000 // 30 seconds grace period
|
||||||
|
|
||||||
|
export class SimpleNowPlayingDetector {
|
||||||
|
/**
|
||||||
|
* Check if a track is currently playing based on simple time calculation
|
||||||
|
*/
|
||||||
|
isTrackPlaying(scrobbleTime: Date, durationMs: number): boolean {
|
||||||
|
const now = new Date()
|
||||||
|
const elapsed = now.getTime() - scrobbleTime.getTime()
|
||||||
|
const maxPlayTime = durationMs + BUFFER_TIME_MS
|
||||||
|
|
||||||
|
const isPlaying = elapsed >= 0 && elapsed <= maxPlayTime
|
||||||
|
|
||||||
|
logger.music('debug', `Track playing check: elapsed=${Math.round(elapsed/1000)}s, duration=${Math.round(durationMs/1000)}s, maxPlay=${Math.round(maxPlayTime/1000)}s, isPlaying=${isPlaying}`)
|
||||||
|
|
||||||
|
// Track is playing if we're within the duration + buffer
|
||||||
|
return isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process albums and determine which one is playing
|
||||||
|
* Returns albums with updated isNowPlaying status
|
||||||
|
*/
|
||||||
|
async processAlbums(
|
||||||
|
albums: Album[],
|
||||||
|
recentTracks: any[],
|
||||||
|
appleMusicDataLookup: (artistName: string, albumName: string) => Promise<any>
|
||||||
|
): Promise<Album[]> {
|
||||||
|
logger.music('debug', `Processing ${albums.length} albums with ${recentTracks.length} recent tracks`)
|
||||||
|
|
||||||
|
// First check if Last.fm reports anything as officially playing
|
||||||
|
const officialNowPlaying = recentTracks.find(track => track.nowPlaying)
|
||||||
|
|
||||||
|
if (officialNowPlaying) {
|
||||||
|
// Trust Last.fm's official now playing status
|
||||||
|
logger.music('debug', `✅ Last.fm official now playing: "${officialNowPlaying.name}" by ${officialNowPlaying.artist.name}`)
|
||||||
|
|
||||||
|
return albums.map(album => ({
|
||||||
|
...album,
|
||||||
|
isNowPlaying:
|
||||||
|
album.name === officialNowPlaying.album.name &&
|
||||||
|
album.artist.name === officialNowPlaying.artist.name,
|
||||||
|
nowPlayingTrack:
|
||||||
|
album.name === officialNowPlaying.album.name &&
|
||||||
|
album.artist.name === officialNowPlaying.artist.name
|
||||||
|
? officialNowPlaying.name
|
||||||
|
: undefined,
|
||||||
|
lastScrobbleTime:
|
||||||
|
album.name === officialNowPlaying.album.name &&
|
||||||
|
album.artist.name === officialNowPlaying.artist.name
|
||||||
|
? new Date() // Now playing tracks are playing right now
|
||||||
|
: album.lastScrobbleTime
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to duration-based detection
|
||||||
|
logger.music('debug', 'Using duration-based detection')
|
||||||
|
|
||||||
|
// Find the most recent track across all albums
|
||||||
|
let mostRecentTrack: any = null
|
||||||
|
let mostRecentTime = new Date(0)
|
||||||
|
|
||||||
|
for (const track of recentTracks) {
|
||||||
|
if (track.date && track.date > mostRecentTime) {
|
||||||
|
mostRecentTime = track.date
|
||||||
|
mostRecentTrack = track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mostRecentTrack) {
|
||||||
|
// No recent tracks, nothing is playing
|
||||||
|
logger.music('debug', '❌ No recent tracks found, nothing is playing')
|
||||||
|
return albums.map(album => ({
|
||||||
|
...album,
|
||||||
|
isNowPlaying: false,
|
||||||
|
nowPlayingTrack: undefined
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.music('debug', `Most recent track: "${mostRecentTrack.name}" by ${mostRecentTrack.artist.name} from ${mostRecentTrack.album.name}`)
|
||||||
|
logger.music('debug', `Scrobbled at: ${mostRecentTrack.date}`)
|
||||||
|
|
||||||
|
// Check if the most recent track is still playing
|
||||||
|
const albumKey = `${mostRecentTrack.artist.name}:${mostRecentTrack.album.name}`
|
||||||
|
let isPlaying = false
|
||||||
|
let playingTrack: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appleMusicData = await appleMusicDataLookup(
|
||||||
|
mostRecentTrack.artist.name,
|
||||||
|
mostRecentTrack.album.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (appleMusicData?.tracks) {
|
||||||
|
const trackData = appleMusicData.tracks.find(
|
||||||
|
(t: any) => t.name.toLowerCase() === mostRecentTrack.name.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (trackData?.durationMs) {
|
||||||
|
isPlaying = this.isTrackPlaying(mostRecentTrack.date, trackData.durationMs)
|
||||||
|
if (isPlaying) {
|
||||||
|
playingTrack = mostRecentTrack.name
|
||||||
|
logger.music('debug', `✅ "${playingTrack}" is still playing`)
|
||||||
|
} else {
|
||||||
|
logger.music('debug', `❌ "${mostRecentTrack.name}" has finished playing`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.music('debug', `⚠️ No duration found for track "${mostRecentTrack.name}"`)
|
||||||
|
// Fallback: assume track is playing if scrobbled within last 5 minutes
|
||||||
|
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
|
||||||
|
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
|
||||||
|
isPlaying = true
|
||||||
|
playingTrack = mostRecentTrack.name
|
||||||
|
logger.music('debug', `⏰ Using time-based fallback: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago, assuming still playing`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking track duration:', error as Error, undefined, 'music')
|
||||||
|
logger.music('debug', `❌ Failed to get Apple Music data for ${mostRecentTrack.artist.name} - ${mostRecentTrack.album.name}`)
|
||||||
|
|
||||||
|
// Fallback when Apple Music lookup fails
|
||||||
|
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
|
||||||
|
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
|
||||||
|
isPlaying = true
|
||||||
|
playingTrack = mostRecentTrack.name
|
||||||
|
logger.music('debug', `⏰ Using time-based fallback after Apple Music error: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update albums with the result
|
||||||
|
return albums.map(album => {
|
||||||
|
const key = `${album.artist.name}:${album.name}`
|
||||||
|
const isThisAlbumPlaying = isPlaying && key === albumKey
|
||||||
|
return {
|
||||||
|
...album,
|
||||||
|
isNowPlaying: isThisAlbumPlaying,
|
||||||
|
nowPlayingTrack: isThisAlbumPlaying ? playingTrack : undefined,
|
||||||
|
lastScrobbleTime: isThisAlbumPlaying ? mostRecentTrack.date : album.lastScrobbleTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import Header from '$components/Header.svelte'
|
import Header from '$components/Header.svelte'
|
||||||
import Footer from '$components/Footer.svelte'
|
import Footer from '$components/Footer.svelte'
|
||||||
|
import DebugPanel from '$components/DebugPanel.svelte'
|
||||||
import { generatePersonJsonLd } from '$lib/utils/metadata'
|
import { generatePersonJsonLd } from '$lib/utils/metadata'
|
||||||
import { Toaster } from 'svelte-sonner'
|
import { Toaster } from 'svelte-sonner'
|
||||||
|
|
||||||
|
|
@ -54,6 +55,9 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Debug Panel (dev only) -->
|
||||||
|
<DebugPanel />
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
:global(html) {
|
:global(html) {
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue