From c75faf6f30a6edbb70023e48643dabb018781ede Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:54:38 -0400 Subject: [PATCH] api memory regression fix --- src/main/services/astraIntegration.ts | 98 +++++++++----- test/astra-integration.test.ts | 187 ++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 32 deletions(-) diff --git a/src/main/services/astraIntegration.ts b/src/main/services/astraIntegration.ts index 7038233..1921f7c 100644 --- a/src/main/services/astraIntegration.ts +++ b/src/main/services/astraIntegration.ts @@ -43,8 +43,27 @@ interface AstraIntegrationServiceOptions { clearTimeoutImpl?: typeof clearTimeout } +function cloneTrackSnapshot(track: AstraTrackSnapshot | null): AstraTrackSnapshot | null { + if (!track) return null + return { ...track } +} + +function cloneSnapshot(snapshot: AstraNowPlayingSnapshot | null): AstraNowPlayingSnapshot | null { + if (!snapshot) return null + return { + ...snapshot, + currentTrack: cloneTrackSnapshot(snapshot.currentTrack), + } +} + function cloneState(state: AstraIntegrationState): AstraIntegrationState { - return JSON.parse(JSON.stringify(state)) as AstraIntegrationState + return { + config: { ...state.config }, + connectionState: state.connectionState, + lastError: state.lastError, + lastControlError: state.lastControlError, + snapshot: cloneSnapshot(state.snapshot), + } } function getErrorMessage(error: unknown, fallback: string): string { @@ -76,6 +95,11 @@ function bufferToDataUrl(bytes: Buffer, mimeType: string): string { return `data:${mimeType};base64,${bytes.toString('base64')}` } +function getArtworkKey(trackId: string | null, artworkUrl: string | null): string | null { + if (!trackId || !artworkUrl) return null + return `${trackId}\n${artworkUrl}` +} + function normalizeBaseUrl(value: unknown): string { if (typeof value !== 'string') return DEFAULT_ASTRA_BASE_URL const trimmed = value.trim() @@ -215,8 +239,9 @@ export class AstraIntegrationService { private state = createDefaultState() private remoteSnapshot: RemoteNowPlayingSnapshot | null = null - private currentArtworkTrackId: string | null = null + private currentArtworkKey: string | null = null private currentArtworkDataUrl: string | null = null + private failedArtworkKey: string | null = null private streamAbortController: AbortController | null = null private reconnectTimer: TimerHandle | null = null private reconnectAttempt = 0 @@ -362,8 +387,9 @@ export class AstraIntegrationService { if (!this.isScopeActive() || this.disposed) { this.remoteSnapshot = null - this.currentArtworkTrackId = null + this.currentArtworkKey = null this.currentArtworkDataUrl = null + this.failedArtworkKey = null this.state = { ...this.state, connectionState: 'disabled', @@ -390,6 +416,7 @@ export class AstraIntegrationService { connectionState: 'connecting', lastError: null, } + this.failedArtworkKey = null this.emitState() try { @@ -577,7 +604,10 @@ export class AstraIntegrationService { private applyRemoteSnapshot(snapshot: RemoteNowPlayingSnapshot): void { this.remoteSnapshot = snapshot - const artworkDataUrl = snapshot.currentTrack?.id === this.currentArtworkTrackId + const artworkDataUrl = getArtworkKey( + snapshot.currentTrack?.id ?? null, + snapshot.currentTrack?.artworkUrl ?? null, + ) === this.currentArtworkKey ? this.currentArtworkDataUrl : null @@ -589,42 +619,41 @@ export class AstraIntegrationService { } private async refreshArtwork(trackId: string | null, artworkUrl: string | null): Promise { - if (!trackId || !artworkUrl || !this.remoteSnapshot?.currentTrack || this.remoteSnapshot.currentTrack.id !== trackId) { - this.currentArtworkTrackId = trackId + const artworkKey = getArtworkKey(trackId, artworkUrl) + const remoteArtworkKey = getArtworkKey( + this.remoteSnapshot?.currentTrack?.id ?? null, + this.remoteSnapshot?.currentTrack?.artworkUrl ?? null, + ) + + if (!artworkKey || remoteArtworkKey !== artworkKey) { + this.currentArtworkKey = null this.currentArtworkDataUrl = null - if (this.remoteSnapshot) { - this.state = { - ...this.state, - snapshot: toRendererSnapshot(this.remoteSnapshot, null), - } - this.emitState() - } + this.failedArtworkKey = null return } - if (this.currentArtworkTrackId === trackId && this.currentArtworkDataUrl) { - this.state = { - ...this.state, - snapshot: toRendererSnapshot(this.remoteSnapshot, this.currentArtworkDataUrl), - } - this.emitState() + if (this.currentArtworkKey === artworkKey && this.currentArtworkDataUrl) { return } - const response = await this.fetchImpl(artworkUrl, { + if (this.failedArtworkKey === artworkKey) { + return + } + + this.currentArtworkKey = null + this.currentArtworkDataUrl = null + + if (artworkUrl === null) { + return + } + + const resolvedArtworkUrl = artworkUrl + const response = await this.fetchImpl(resolvedArtworkUrl, { headers: this.buildAuthHeaders(), }).catch(() => null) if (!response || !response.ok) { - this.currentArtworkTrackId = trackId - this.currentArtworkDataUrl = null - if (this.remoteSnapshot?.currentTrack?.id === trackId) { - this.state = { - ...this.state, - snapshot: toRendererSnapshot(this.remoteSnapshot, null), - } - this.emitState() - } + this.failedArtworkKey = artworkKey return } @@ -632,15 +661,20 @@ export class AstraIntegrationService { const bytes = Buffer.from(await response.arrayBuffer()) const artworkDataUrl = bufferToDataUrl(bytes, mimeType) - if (this.remoteSnapshot?.currentTrack?.id !== trackId) { + const remoteSnapshot = this.remoteSnapshot + if (!remoteSnapshot || getArtworkKey( + remoteSnapshot.currentTrack?.id ?? null, + remoteSnapshot.currentTrack?.artworkUrl ?? null, + ) !== artworkKey) { return } - this.currentArtworkTrackId = trackId + this.currentArtworkKey = artworkKey this.currentArtworkDataUrl = artworkDataUrl + this.failedArtworkKey = null this.state = { ...this.state, - snapshot: toRendererSnapshot(this.remoteSnapshot, artworkDataUrl), + snapshot: toRendererSnapshot(remoteSnapshot, artworkDataUrl), } this.emitState() } diff --git a/test/astra-integration.test.ts b/test/astra-integration.test.ts index 25ec60a..c0229b3 100644 --- a/test/astra-integration.test.ts +++ b/test/astra-integration.test.ts @@ -232,6 +232,193 @@ test('service initializes from config, hydrates artwork, and applies SSE updates } }) +test('service emits a single state update when reusing cached artwork on SSE updates', async () => { + const harness = await createConfigFile({ + baseUrl: DEFAULT_ASTRA_BASE_URL, + token: 'secret-token', + }) + const sse = createSseStream() + const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-1` + let stateUpdateCount = 0 + + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input) + const pathname = new URL(url).pathname + const headers = createHeaders(init) + assert.equal(headers.get('authorization'), 'Bearer secret-token') + + if (pathname === '/v1/now-playing') { + return createJsonResponse({ + playbackState: 'playing', + currentTime: 15, + duration: 180, + queueLength: 2, + outputDeviceLabel: 'Built-in Output', + visualizerLineColor: '#4ade80', + updatedAt: 1000, + currentTrack: { + id: 'track-1', + title: 'Song One', + artist: 'Artist One', + album: 'Album One', + isFavorite: false, + artworkUrl, + }, + }) + } + + if (pathname === '/v1/artwork/current') { + return createPngResponse('cover-one') + } + + if (pathname === '/v1/events') { + return sse.response + } + + throw new Error(`Unexpected request: ${url}`) + } + + const service = new AstraIntegrationService({ + configPath: harness.configPath, + fetchImpl, + now: () => 1000, + }) + + try { + service.subscribe((state) => { + void state + stateUpdateCount += 1 + }) + + await service.initialize() + await service.setConsumerActive(1, true) + await waitFor(() => service.getState().connectionState === 'connected', 'expected connected Astra state') + + const baseUpdateCount = stateUpdateCount + + sse.pushEvent('now-playing', { + playbackState: 'playing', + currentTime: 42, + duration: 180, + queueLength: 2, + outputDeviceLabel: 'Built-in Output', + visualizerLineColor: '#4ade80', + updatedAt: 2000, + currentTrack: { + id: 'track-1', + title: 'Song One', + artist: 'Artist One', + album: 'Album One', + isFavorite: false, + artworkUrl, + }, + }) + + await waitFor(() => service.getState().snapshot?.updatedAt === 2000, 'expected SSE update to apply') + assert.equal(stateUpdateCount - baseUpdateCount, 1) + } finally { + await service.dispose() + await harness.cleanup() + } +}) + +test('service does not refetch identical artwork after a failed attempt', async () => { + const harness = await createConfigFile({ + baseUrl: DEFAULT_ASTRA_BASE_URL, + token: 'secret-token', + }) + const sse = createSseStream() + const artworkUrl = `${DEFAULT_ASTRA_BASE_URL}/v1/artwork/current?trackId=track-2` + let artworkRequests = 0 + + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input) + const pathname = new URL(url).pathname + const headers = createHeaders(init) + assert.equal(headers.get('authorization'), 'Bearer secret-token') + + if (pathname === '/v1/now-playing') { + return createJsonResponse({ + playbackState: 'paused', + currentTime: 0, + duration: 0, + queueLength: 0, + outputDeviceLabel: null, + visualizerLineColor: '#38bdf8', + updatedAt: 1000, + currentTrack: null, + }) + } + + if (pathname === '/v1/artwork/current') { + artworkRequests += 1 + return createJsonResponse({ error: 'missing artwork' }, 404) + } + + if (pathname === '/v1/events') { + return sse.response + } + + throw new Error(`Unexpected request: ${url}`) + } + + const service = new AstraIntegrationService({ + configPath: harness.configPath, + fetchImpl, + }) + + try { + await service.initialize() + await service.setConsumerActive(1, true) + await waitFor(() => service.getState().connectionState === 'connected', 'expected connected Astra state') + + sse.pushEvent('now-playing', { + playbackState: 'playing', + currentTime: 5, + duration: 180, + queueLength: 1, + outputDeviceLabel: 'Built-in Output', + visualizerLineColor: '#4ade80', + updatedAt: 2000, + currentTrack: { + id: 'track-2', + title: 'Song Two', + artist: 'Artist Two', + album: 'Album Two', + isFavorite: false, + artworkUrl, + }, + }) + + await waitFor(() => service.getState().snapshot?.currentTrack?.id === 'track-2', 'expected first artwork-bearing track update') + await waitFor(() => artworkRequests === 1, 'expected first failed artwork request') + + sse.pushEvent('now-playing', { + playbackState: 'playing', + currentTime: 15, + duration: 180, + queueLength: 1, + outputDeviceLabel: 'Built-in Output', + visualizerLineColor: '#4ade80', + updatedAt: 3000, + currentTrack: { + id: 'track-2', + title: 'Song Two', + artist: 'Artist Two', + album: 'Album Two', + isFavorite: false, + artworkUrl, + }, + }) + + await waitFor(() => service.getState().snapshot?.updatedAt === 3000, 'expected second track update') + assert.equal(artworkRequests, 1) + } finally { + await service.dispose() + await harness.cleanup() + } +}) + test('service schedules reconnect when the SSE stream closes', async () => { const harness = await createConfigFile({ baseUrl: DEFAULT_ASTRA_BASE_URL,