-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathspotifyPolling.ts
More file actions
468 lines (425 loc) · 14.3 KB
/
Copy pathspotifyPolling.ts
File metadata and controls
468 lines (425 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
import { AccessToken, SpotifyApi } from '@spotify/web-api-ts-sdk'
import { ServerMessage, SpotifyData } from '../types/websocket'
import { SpotifyTokenManager } from './spotifyTokenManager.js'
import logger from '../utils/logger.js'
// Utility: Safely parse JSON, fallback to text
function safeParseJSON(input: string): unknown {
try {
return JSON.parse(input)
} catch {
return input // Return raw text if not JSON
}
}
// API endpoint constants (mostly managed by SDK now)
// TOKEN_URL is handled by TokenManager or SDK
type SpotifyCommand =
| 'PLAY'
| 'NEXT'
| 'PREVIOUS'
| 'LOGIN'
| 'TRANSFER_PLAYBACK'
| 'SET_VOLUME'
| 'PAUSE'
// We use SDK types now, but keep internal state types as needed.
// Removed manual SpotifyCurrentlyPlayingResponse, SpotifyDevice, etc.
export interface SpotifyTokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
scope: string
}
export class SpotifyPolling {
/**
* Public method to force a poll and broadcast current track state.
*/
public forcePollAndBroadcast() {
return this.getCurrentlyPlaying()
}
private tokenManager: SpotifyTokenManager
private pollInterval: NodeJS.Timeout | null = null
private tokenRefreshInterval: NodeJS.Timeout | null = null
// Internal auth/state values
private broadcastUpdate: (message: ServerMessage) => void
private lastTrackId: string | null = null
private lastPlaybackState: boolean | null = null
private state: SpotifyData = {
trackName: 'Awaiting Login...',
artist: '',
isPlaying: false,
}
private sdk: SpotifyApi | null = null
private constructor(broadcastUpdate: (message: ServerMessage) => void) {
this.broadcastUpdate = broadcastUpdate
logger.debug('Spotify Polling Service Initialized.')
this.tokenManager = new SpotifyTokenManager(
process.env.SPOTIFY_CLIENT_ID || '',
process.env.SPOTIFY_CLIENT_SECRET || ''
)
}
public static async create(
broadcastUpdate: (message: ServerMessage) => void
): Promise<SpotifyPolling> {
const instance = new SpotifyPolling(broadcastUpdate)
await instance.initializeSdk()
instance.tokenRefreshInterval = setInterval(
() => instance.checkAndRefreshSdkToken(),
1000 * 60 * 5
) // Check every 5 minutes if we need to re-sync
return instance
}
private async initializeSdk() {
const token = await this.tokenManager.getValidAccessToken() // Triggers refresh if needed
if (token) {
const sdkToken = this.tokenManager.getSdkAccessToken()
if (sdkToken) {
this.setupSdk(sdkToken)
logger.debug(
'Loaded existing Spotify tokens from file. Starting polling.'
)
this.startPolling()
}
}
}
private setupSdk(accessToken: AccessToken) {
this.sdk = SpotifyApi.withAccessToken(
process.env.SPOTIFY_CLIENT_ID || '',
accessToken
)
}
private async checkAndRefreshSdkToken() {
// Force Manager to check validity and refresh if needed
const newTokenString = await this.tokenManager.getValidAccessToken()
if (newTokenString && this.sdk) {
const sdkToken = this.tokenManager.getSdkAccessToken()
if (sdkToken) {
this.setupSdk(sdkToken)
}
}
}
public getState(): SpotifyData {
return { ...this.state }
}
/**
* Public method to safely check if the SDK has been initialized.
* @returns {boolean} True if the SDK is ready, false otherwise.
*/
public isReady(): boolean {
return this.sdk !== null
}
// --- Token Management (Used by NextAuth route) ---
/**
* Called by server.ts POST /internal/token-delivery after NextAuth provides the refresh token.
*/
public setRefreshToken(_token: string) {
logger.debug('Spotify Refresh Token signal received. Reloading SDK.')
// Reset the token manager state to ensure it re-reads the file
// Note: TokenManager reads file on every getValidAccessToken call, so we just need to trigger init
setTimeout(() => this.initializeSdk(), 1000) // Give FS a moment to settle
}
// --- Polling Logic ---
// Expose start/stop polling publicly (used by server to control lifecycle)
public startPolling(intervalMs: number = 3000) {
if (this.pollInterval) return
// Poll every `intervalMs` for low-latency updates
this.pollInterval = setInterval(
() => this.getCurrentlyPlaying(),
intervalMs
)
logger.debug('Spotify polling started.')
}
public stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval)
this.pollInterval = null
logger.debug('Spotify polling stopped.')
}
}
public cleanup() {
this.stopPolling()
if (this.tokenRefreshInterval) {
clearInterval(this.tokenRefreshInterval)
this.tokenRefreshInterval = null
logger.debug('Token refresh interval cleared.')
}
}
private getCurrentlyPlaying = async () => {
if (!this.sdk) return
// Ensure token is valid before call?
// We rely on background refresh or failure handling.
try {
let playbackState
try {
playbackState = await this.sdk.player.getCurrentlyPlayingTrack()
} catch (err: unknown) {
// If response is not JSON, fallback to text
if (
typeof err === 'object' &&
err !== null &&
'response' in err &&
typeof (err as { response?: unknown }).response === 'object' &&
(err as { response?: { text?: unknown } }).response &&
'text' in (err as { response: { text?: unknown } }).response &&
typeof (err as { response: { text?: unknown } }).response.text ===
'function'
) {
const text = await (
err as { response: { text: () => Promise<string> } }
).response.text()
const parsed = safeParseJSON(text)
if (typeof parsed === 'object' && parsed !== null) {
logger.error({ response: parsed }, 'Spotify API response (parsed)')
} else {
logger.error({ response: text }, 'Spotify API response (not JSON)')
}
}
throw err
}
if (!playbackState) {
// Nothing playing or 204
if (this.lastPlaybackState !== false) {
this.lastPlaybackState = false
this.state = {
trackName: 'Nothing is currently playing.',
artist: '',
isPlaying: false,
}
this.broadcastUpdate({
type: 'SPOTIFY_UPDATE',
payload: this.getState(),
})
}
return
}
// Check if it's a track or episode
if (
playbackState.currently_playing_type !== 'track' &&
playbackState.currently_playing_type !== 'episode'
) {
// Unknown type
return
}
// item can be null if it's private session or unknown
const item = playbackState.item
// We need to handle Track vs Episode. SDK types are union.
// For simplicity, we access common fields or check type.
const trackName = item?.name || 'Unknown Content'
// Artists exists on Track, not necessarily Episode in the same way?
// SDK `Track` has artists, `Episode` has show.
let artistName = 'Unknown Artist'
if (item && 'artists' in item) {
artistName = item.artists.map((a) => a.name).join(', ')
} else if (item && 'show' in item) {
artistName = item.show.name
}
const isPlaying = playbackState.is_playing
// Only broadcast if track ID or playback state has changed
if (
item?.id !== this.lastTrackId ||
isPlaying !== this.lastPlaybackState
) {
this.lastTrackId = item?.id || null
this.lastPlaybackState = isPlaying
this.state = {
trackName: trackName,
artist: artistName,
isPlaying: isPlaying,
}
this.broadcastUpdate({
type: 'SPOTIFY_UPDATE',
payload: this.getState(),
})
}
} catch (error) {
const err = error as { status?: number }
// Handle 429 specifically
if (err?.status === 429) {
logger.warn('Spotify API Rate Limited. Backing off...')
// Maybe stop polling for a bit?
return
}
if (err?.status === 401) {
logger.warn(
'Spotify token expired during polling. Attempting refresh.'
)
this.checkAndRefreshSdkToken()
return
}
logger.error({ err: error }, 'Error fetching currently playing track')
}
}
// --- Command Handling (Used by socketManager) ---
public async getAvailableDevices() {
if (!this.sdk) {
logger.warn('Cannot get devices: SDK not initialized.')
return []
}
try {
const response = await this.sdk.player.getAvailableDevices()
return response.devices
} catch (error) {
logger.error({ err: error }, 'Error fetching Spotify devices')
return []
}
}
public handleCommand(
command: SpotifyCommand,
deviceId?: string,
volume?: number,
playlistUri?: string
) {
if (!this.sdk) {
logger.warn('Cannot execute command: SDK not initialized.')
return Promise.resolve()
}
return (async () => {
try {
await this.executeSpotifyCommand(command, deviceId, volume, playlistUri)
setTimeout(() => this.getCurrentlyPlaying(), 500)
} catch (error) {
this.logSpotifyCommandError(command, error)
}
})()
}
private async executeSpotifyCommand(
command: SpotifyCommand,
deviceId?: string,
volume?: number,
playlistUri?: string
) {
// Note: We allow deviceId to be undefined for PLAY/PAUSE/NEXT/PREVIOUS
// This triggers the action on the currently active device.
switch (command) {
case 'PLAY':
if (playlistUri) {
// If deviceId is undefined, SDK targets active device
// Type assertion needed because SDK types incorrectly require string
await this.sdk!.player.startResumePlayback(
(deviceId || undefined) as unknown as string,
playlistUri
)
} else {
await this.sdk!.player.startResumePlayback(
(deviceId || undefined) as unknown as string
)
}
break
case 'PAUSE':
await this.sdk!.player.pausePlayback(
(deviceId || undefined) as unknown as string
)
break
case 'NEXT':
await this.sdk!.player.skipToNext(
(deviceId || undefined) as unknown as string
)
break
case 'PREVIOUS':
await this.sdk!.player.skipToPrevious(
(deviceId || undefined) as unknown as string
)
break
case 'TRANSFER_PLAYBACK':
if (deviceId) {
await this.sdk!.player.transferPlayback([deviceId], true)
}
break
case 'SET_VOLUME':
if (volume !== undefined) {
const clampedVolume = Math.max(0, Math.min(100, Math.round(volume)))
await this.sdk!.player.setPlaybackVolume(clampedVolume, deviceId)
}
break
case 'LOGIN':
logger.debug('Received LOGIN command.')
break
default:
logger.warn(`Unknown Spotify command: ${command}`)
}
}
private async logSpotifyCommandError(
command: SpotifyCommand,
error: unknown
) {
try {
if (error instanceof SyntaxError) {
// Suppress SyntaxError which usually occurs when Spotify returns a non-JSON response (e.g. 204 No Content or simple text error)
// This is "expected" behavior from the SDK in some edge cases.
logger.warn(
`[SpotifyPolling] Command ${command} executed, but response was not valid JSON (likely 204 No Content). SyntaxError suppressed.`
)
} else if (error && typeof error === 'object') {
if (
'response' in error &&
(error as { response?: { text?: () => Promise<string> } }).response
) {
try {
let text = '[No response text available]'
if (
typeof error === 'object' &&
error !== null &&
'response' in error &&
typeof (error as { response?: unknown }).response === 'object' &&
(error as { response?: { text?: unknown } }).response &&
'text' in (error as { response: { text?: unknown } }).response &&
typeof (error as { response: { text?: unknown } }).response
.text === 'function'
) {
try {
text = await (
error as { response: { text: () => Promise<string> } }
).response.text()
} catch (textError) {
// Sometimes calling text() itself might fail if body was already consumed or invalid
logger.error(
{ err: textError },
`Error executing Spotify command ${command}: Failed to retrieve error response text:`
)
logger.error(
{ err: error },
`Error executing Spotify command ${command}:`
)
return
}
const parsed = safeParseJSON(text)
if (typeof parsed === 'object' && parsed !== null) {
logger.error(
{ response: parsed },
`Error executing Spotify command ${command}: Parsed response:`
)
} else {
logger.error(
{ response: text },
`Error executing Spotify command ${command}: Response body:`
)
}
}
} catch (e) {
logger.error(
{ err: e },
`Error executing Spotify command ${command}: Could not read response body.`
)
}
} else {
// Log other object errors
logger.error(
{ err: error },
`Error executing Spotify command ${command}:`
)
}
} else {
logger.error(
{ err: error },
`Error executing Spotify command ${command}:`
)
}
} catch (loggingError) {
// Absolute failsafe to prevent logger from crashing the app
logger.error(
{ err: loggingError },
`Error executing Spotify command ${command}: (Logging failed)`
)
logger.error({ err: error }, `Original error for ${command}:`)
}
}
}