audio-channel-queue 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/errors.ts ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * @fileoverview Error handling, retry logic, and recovery mechanisms for the audio-channel-queue package
3
+ */
4
+
5
+ import { AudioErrorInfo, AudioErrorCallback, RetryConfig, ErrorRecoveryOptions, ExtendedAudioQueueChannel } from './types';
6
+ import { audioChannels } from './info';
7
+ import { extractFileName } from './utils';
8
+
9
+ let globalRetryConfig: RetryConfig = {
10
+ enabled: true,
11
+ maxRetries: 3,
12
+ baseDelay: 1000,
13
+ exponentialBackoff: true,
14
+ timeoutMs: 10000,
15
+ skipOnFailure: false
16
+ };
17
+
18
+ let globalErrorRecovery: ErrorRecoveryOptions = {
19
+ autoRetry: true,
20
+ showUserFeedback: false,
21
+ logErrorsToAnalytics: false,
22
+ preserveQueueOnError: true,
23
+ fallbackToNextTrack: true
24
+ };
25
+
26
+ const retryAttempts = new WeakMap<HTMLAudioElement, number>();
27
+
28
+ const loadTimeouts = new WeakMap<HTMLAudioElement, number>();
29
+
30
+ /**
31
+ * Subscribes to audio error events for a specific channel
32
+ * @param channelNumber - The channel number to listen to (defaults to 0)
33
+ * @param callback - Function to call when an audio error occurs
34
+ * @example
35
+ * ```typescript
36
+ * onAudioError(0, (errorInfo) => {
37
+ * console.log(`Audio error: ${errorInfo.error.message}`);
38
+ * console.log(`Error type: ${errorInfo.errorType}`);
39
+ * });
40
+ * ```
41
+ */
42
+ export const onAudioError = (channelNumber: number = 0, callback: AudioErrorCallback): void => {
43
+ // Ensure channel exists
44
+ while (audioChannels.length <= channelNumber) {
45
+ audioChannels.push({
46
+ audioCompleteCallbacks: new Set(),
47
+ audioErrorCallbacks: new Set(),
48
+ audioPauseCallbacks: new Set(),
49
+ audioResumeCallbacks: new Set(),
50
+ audioStartCallbacks: new Set(),
51
+ isPaused: false,
52
+ progressCallbacks: new Map(),
53
+ queue: [],
54
+ queueChangeCallbacks: new Set(),
55
+ volume: 1.0
56
+ });
57
+ }
58
+
59
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
60
+ if (!channel.audioErrorCallbacks) {
61
+ channel.audioErrorCallbacks = new Set();
62
+ }
63
+
64
+ channel.audioErrorCallbacks.add(callback);
65
+ };
66
+
67
+ /**
68
+ * Unsubscribes from audio error events for a specific channel
69
+ * @param channelNumber - The channel number to stop listening to (defaults to 0)
70
+ * @param callback - The specific callback to remove (optional - if not provided, removes all)
71
+ * @example
72
+ * ```typescript
73
+ * offAudioError(0); // Remove all error callbacks for channel 0
74
+ * offAudioError(0, specificCallback); // Remove specific callback
75
+ * ```
76
+ */
77
+ export const offAudioError = (channelNumber: number = 0, callback?: AudioErrorCallback): void => {
78
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
79
+ if (!channel?.audioErrorCallbacks) return;
80
+
81
+ if (callback) {
82
+ channel.audioErrorCallbacks.delete(callback);
83
+ } else {
84
+ channel.audioErrorCallbacks.clear();
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Sets the global retry configuration for audio loading failures
90
+ * @param config - Retry configuration options
91
+ * @example
92
+ * ```typescript
93
+ * setRetryConfig({
94
+ * enabled: true,
95
+ * maxRetries: 5,
96
+ * baseDelay: 1000,
97
+ * exponentialBackoff: true,
98
+ * timeoutMs: 15000,
99
+ * fallbackUrls: ['https://cdn.backup.com/audio/'],
100
+ * skipOnFailure: true
101
+ * });
102
+ * ```
103
+ */
104
+ export const setRetryConfig = (config: Partial<RetryConfig>): void => {
105
+ globalRetryConfig = { ...globalRetryConfig, ...config };
106
+ };
107
+
108
+ /**
109
+ * Gets the current global retry configuration
110
+ * @returns Current retry configuration
111
+ * @example
112
+ * ```typescript
113
+ * const config = getRetryConfig();
114
+ * console.log(`Max retries: ${config.maxRetries}`);
115
+ * ```
116
+ */
117
+ export const getRetryConfig = (): RetryConfig => {
118
+ return { ...globalRetryConfig };
119
+ };
120
+
121
+ /**
122
+ * Sets the global error recovery configuration
123
+ * @param options - Error recovery options
124
+ * @example
125
+ * ```typescript
126
+ * setErrorRecovery({
127
+ * autoRetry: true,
128
+ * showUserFeedback: true,
129
+ * logErrorsToAnalytics: true,
130
+ * preserveQueueOnError: true,
131
+ * fallbackToNextTrack: true
132
+ * });
133
+ * ```
134
+ */
135
+ export const setErrorRecovery = (options: Partial<ErrorRecoveryOptions>): void => {
136
+ globalErrorRecovery = { ...globalErrorRecovery, ...options };
137
+ };
138
+
139
+ /**
140
+ * Gets the current global error recovery configuration
141
+ * @returns Current error recovery configuration
142
+ * @example
143
+ * ```typescript
144
+ * const recovery = getErrorRecovery();
145
+ * console.log(`Auto retry enabled: ${recovery.autoRetry}`);
146
+ * ```
147
+ */
148
+ export const getErrorRecovery = (): ErrorRecoveryOptions => {
149
+ return { ...globalErrorRecovery };
150
+ };
151
+
152
+ /**
153
+ * Manually retries loading failed audio for a specific channel
154
+ * @param channelNumber - The channel number to retry (defaults to 0)
155
+ * @returns Promise that resolves to true if retry was successful, false otherwise
156
+ * @example
157
+ * ```typescript
158
+ * const success = await retryFailedAudio(0);
159
+ * if (success) {
160
+ * console.log('Audio retry successful');
161
+ * } else {
162
+ * console.log('Audio retry failed');
163
+ * }
164
+ * ```
165
+ */
166
+ export const retryFailedAudio = async (channelNumber: number = 0): Promise<boolean> => {
167
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
168
+ if (!channel || channel.queue.length === 0) return false;
169
+
170
+ const currentAudio: HTMLAudioElement = channel.queue[0];
171
+ const currentAttempts = retryAttempts.get(currentAudio) || 0;
172
+
173
+ if (currentAttempts >= globalRetryConfig.maxRetries) {
174
+ return false;
175
+ }
176
+
177
+ try {
178
+ // Reset the audio element
179
+ currentAudio.currentTime = 0;
180
+ await currentAudio.play();
181
+
182
+ // Reset retry counter on successful play
183
+ retryAttempts.delete(currentAudio);
184
+ return true;
185
+ } catch (error) {
186
+ // Increment retry counter
187
+ retryAttempts.set(currentAudio, currentAttempts + 1);
188
+ return false;
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Emits an audio error event to all registered listeners for a specific channel
194
+ * @param channelNumber - The channel number where the error occurred
195
+ * @param errorInfo - Information about the error
196
+ * @param audioChannels - Array of audio channels
197
+ * @internal
198
+ */
199
+ export const emitAudioError = (
200
+ channelNumber: number,
201
+ errorInfo: AudioErrorInfo,
202
+ audioChannels: ExtendedAudioQueueChannel[]
203
+ ): void => {
204
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
205
+ if (!channel?.audioErrorCallbacks) return;
206
+
207
+ // Log to analytics if enabled
208
+ if (globalErrorRecovery.logErrorsToAnalytics) {
209
+ console.warn('Audio Error Analytics:', errorInfo);
210
+ }
211
+
212
+ channel.audioErrorCallbacks.forEach(callback => {
213
+ try {
214
+ callback(errorInfo);
215
+ } catch (error) {
216
+ console.error('Error in audio error callback:', error);
217
+ }
218
+ });
219
+ };
220
+
221
+ /**
222
+ * Determines the error type based on the error object and context
223
+ * @param error - The error that occurred
224
+ * @param audio - The audio element that failed
225
+ * @returns The categorized error type
226
+ * @internal
227
+ */
228
+ export const categorizeError = (error: Error, audio: HTMLAudioElement): AudioErrorInfo['errorType'] => {
229
+ const errorMessage = error.message.toLowerCase();
230
+
231
+ if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
232
+ return 'network';
233
+ }
234
+
235
+ // Check for unsupported format first (more specific than decode)
236
+ if (errorMessage.includes('not supported') || errorMessage.includes('unsupported') ||
237
+ errorMessage.includes('format not supported')) {
238
+ return 'unsupported';
239
+ }
240
+
241
+ if (errorMessage.includes('decode') || errorMessage.includes('format')) {
242
+ return 'decode';
243
+ }
244
+
245
+ if (errorMessage.includes('permission') || errorMessage.includes('blocked')) {
246
+ return 'permission';
247
+ }
248
+
249
+ if (errorMessage.includes('abort')) {
250
+ return 'abort';
251
+ }
252
+
253
+ if (errorMessage.includes('timeout')) {
254
+ return 'timeout';
255
+ }
256
+
257
+ // Check audio element network state for more context
258
+ if (audio.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) {
259
+ return 'network';
260
+ }
261
+
262
+ if (audio.networkState === HTMLMediaElement.NETWORK_LOADING) {
263
+ return 'timeout';
264
+ }
265
+
266
+ return 'unknown';
267
+ };
268
+
269
+ /**
270
+ * Sets up comprehensive error handling for an audio element
271
+ * @param audio - The audio element to set up error handling for
272
+ * @param channelNumber - The channel number this audio belongs to
273
+ * @param originalUrl - The original URL that was requested
274
+ * @param onError - Callback for when an error occurs
275
+ * @internal
276
+ */
277
+ export const setupAudioErrorHandling = (
278
+ audio: HTMLAudioElement,
279
+ channelNumber: number,
280
+ originalUrl: string,
281
+ onError?: (error: Error) => Promise<void>
282
+ ): void => {
283
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
284
+ if (!channel) return;
285
+
286
+ // Set up loading timeout with test environment compatibility
287
+ let timeoutId: number;
288
+ if (typeof setTimeout !== 'undefined') {
289
+ timeoutId = setTimeout(() => {
290
+ if (audio.networkState === HTMLMediaElement.NETWORK_LOADING) {
291
+ const timeoutError = new Error(`Audio loading timeout after ${globalRetryConfig.timeoutMs}ms`);
292
+ handleAudioError(audio, channelNumber, originalUrl, timeoutError);
293
+ }
294
+ }, globalRetryConfig.timeoutMs) as unknown as number;
295
+
296
+ loadTimeouts.set(audio, timeoutId);
297
+ }
298
+
299
+ // Clear timeout when metadata loads successfully
300
+ const handleLoadSuccess = (): void => {
301
+ if (typeof setTimeout !== 'undefined') {
302
+ const timeoutId = loadTimeouts.get(audio);
303
+ if (timeoutId) {
304
+ clearTimeout(timeoutId);
305
+ loadTimeouts.delete(audio);
306
+ }
307
+ }
308
+ };
309
+
310
+ // Handle various error events
311
+ const handleError = (event: Event): void => {
312
+ if (typeof setTimeout !== 'undefined') {
313
+ const timeoutId = loadTimeouts.get(audio);
314
+ if (timeoutId) {
315
+ clearTimeout(timeoutId);
316
+ loadTimeouts.delete(audio);
317
+ }
318
+ }
319
+
320
+ const error = new Error(`Audio loading failed: ${audio.error?.message || 'Unknown error'}`);
321
+ handleAudioError(audio, channelNumber, originalUrl, error);
322
+ };
323
+
324
+ const handleAbort = (): void => {
325
+ const error = new Error('Audio loading was aborted');
326
+ handleAudioError(audio, channelNumber, originalUrl, error);
327
+ };
328
+
329
+ const handleStall = (): void => {
330
+ const error = new Error('Audio loading stalled');
331
+ handleAudioError(audio, channelNumber, originalUrl, error);
332
+ };
333
+
334
+ // Add event listeners
335
+ audio.addEventListener('error', handleError);
336
+ audio.addEventListener('abort', handleAbort);
337
+ audio.addEventListener('stalled', handleStall);
338
+ audio.addEventListener('loadedmetadata', handleLoadSuccess);
339
+ audio.addEventListener('canplay', handleLoadSuccess);
340
+
341
+ // Custom play error handling
342
+ if (onError) {
343
+ const originalPlay = audio.play.bind(audio);
344
+ const wrappedPlay = async () => {
345
+ try {
346
+ await originalPlay();
347
+ } catch (error) {
348
+ await onError(error as Error);
349
+ throw error;
350
+ }
351
+ };
352
+
353
+ audio.play = wrappedPlay;
354
+ }
355
+ };
356
+
357
+ /**
358
+ * Handles audio errors with retry logic and recovery mechanisms
359
+ * @param audio - The audio element that failed
360
+ * @param channelNumber - The channel number
361
+ * @param originalUrl - The original URL that was requested
362
+ * @param error - The error that occurred
363
+ * @internal
364
+ */
365
+ export const handleAudioError = async (
366
+ audio: HTMLAudioElement,
367
+ channelNumber: number,
368
+ originalUrl: string,
369
+ error: Error
370
+ ): Promise<void> => {
371
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
372
+ if (!channel) return;
373
+
374
+ const currentAttempts = retryAttempts.get(audio) || 0;
375
+ const retryConfig = channel.retryConfig || globalRetryConfig;
376
+
377
+ const errorInfo: AudioErrorInfo = {
378
+ channelNumber,
379
+ src: originalUrl,
380
+ fileName: extractFileName(originalUrl),
381
+ error,
382
+ errorType: categorizeError(error, audio),
383
+ timestamp: Date.now(),
384
+ retryAttempt: currentAttempts,
385
+ remainingInQueue: channel.queue.length - 1
386
+ };
387
+
388
+ // Emit error event
389
+ emitAudioError(channelNumber, errorInfo, audioChannels);
390
+
391
+ // Attempt retry if enabled and within limits
392
+ if (retryConfig.enabled && currentAttempts < retryConfig.maxRetries && globalErrorRecovery.autoRetry) {
393
+ const delay = retryConfig.exponentialBackoff
394
+ ? retryConfig.baseDelay * Math.pow(2, currentAttempts)
395
+ : retryConfig.baseDelay;
396
+
397
+ retryAttempts.set(audio, currentAttempts + 1);
398
+
399
+ const retryFunction = async () => {
400
+ try {
401
+ // Try fallback URLs if available
402
+ if (retryConfig.fallbackUrls && retryConfig.fallbackUrls.length > 0) {
403
+ const fallbackIndex = currentAttempts % retryConfig.fallbackUrls.length;
404
+ const fallbackUrl = retryConfig.fallbackUrls[fallbackIndex] + extractFileName(originalUrl);
405
+ audio.src = fallbackUrl;
406
+ }
407
+
408
+ await audio.load();
409
+ await audio.play();
410
+
411
+ // Reset retry counter on success
412
+ retryAttempts.delete(audio);
413
+ } catch (retryError) {
414
+ await handleAudioError(audio, channelNumber, originalUrl, retryError as Error);
415
+ }
416
+ };
417
+
418
+ setTimeout(retryFunction, delay);
419
+ } else {
420
+ // Max retries reached or retry disabled
421
+ if (retryConfig.skipOnFailure || globalErrorRecovery.fallbackToNextTrack) {
422
+ // Skip to next track in queue
423
+ channel.queue.shift();
424
+
425
+ // Import and use playAudioQueue to continue with next track
426
+ const { playAudioQueue } = await import('./core');
427
+ playAudioQueue(channelNumber).catch(console.error);
428
+ } else if (!globalErrorRecovery.preserveQueueOnError) {
429
+ // Clear the entire queue on failure
430
+ channel.queue = [];
431
+ }
432
+ }
433
+ };
434
+
435
+ /**
436
+ * Creates a timeout-protected audio element with comprehensive error handling
437
+ * @param url - The audio URL to load
438
+ * @param channelNumber - The channel number this audio belongs to
439
+ * @returns Promise that resolves to the configured audio element
440
+ * @internal
441
+ */
442
+ export const createProtectedAudioElement = async (
443
+ url: string,
444
+ channelNumber: number
445
+ ): Promise<HTMLAudioElement> => {
446
+ const audio = new Audio();
447
+
448
+ return new Promise((resolve, reject) => {
449
+ const cleanup = (): void => {
450
+ const timeoutId = loadTimeouts.get(audio);
451
+ if (timeoutId) {
452
+ clearTimeout(timeoutId);
453
+ loadTimeouts.delete(audio);
454
+ }
455
+ };
456
+
457
+ const handleSuccess = (): void => {
458
+ cleanup();
459
+ resolve(audio);
460
+ };
461
+
462
+ const handleError = (error: Error): void => {
463
+ cleanup();
464
+ reject(error);
465
+ };
466
+
467
+ // Set up error handling
468
+ setupAudioErrorHandling(audio, channelNumber, url, async (error: Error) => {
469
+ handleError(error);
470
+ });
471
+
472
+ // Set up success handlers
473
+ audio.addEventListener('canplay', handleSuccess, { once: true });
474
+ audio.addEventListener('loadedmetadata', handleSuccess, { once: true });
475
+
476
+ // Start loading
477
+ audio.src = url;
478
+ audio.load();
479
+ });
480
+ };