mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
api memory regression fix
This commit is contained in:
@@ -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<void> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user