vantiv.io 1.0.9 → 1.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vantiv.io",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Enterprise WebSocket infrastructure for Highrise featuring spatial intelligence systems, memory-optimized architecture, and production-grade reliability for scalable application development",
5
5
  "keywords": [
6
6
  "highrise",
@@ -1,9 +1,9 @@
1
1
  const { IcecastStreamer, IcecastQueue, Track, YouTubeExtractor, IcecastServer } = require('./lib/AudioStreaming.js');
2
- const EventEmitter = require('events')
2
+ const EventEmitter = require('events');
3
3
 
4
4
  class MusicClass extends EventEmitter {
5
5
  constructor(bot, config = {}) {
6
- super()
6
+ super();
7
7
  this.bot = bot;
8
8
  this.config = {
9
9
  enabled: config.enabled || false,
@@ -25,9 +25,8 @@ class MusicClass extends EventEmitter {
25
25
  this.setupEventHandlers();
26
26
 
27
27
  if (this.config.autoStartServer) {
28
- this.startWebServer();
28
+ this._startWebServer();
29
29
  }
30
-
31
30
  }
32
31
 
33
32
  initializeStreamer() {
@@ -36,25 +35,24 @@ class MusicClass extends EventEmitter {
36
35
  this.queue = new IcecastQueue(this.streamer, { ...this.config.queue, ...this.config.icecast });
37
36
 
38
37
  this.streamer.on('playbackStart', (track) => {
39
- this.emit('playbackStart', track);
38
+ this.emit('MusicStart', track);
40
39
  });
41
40
 
42
41
  this.streamer.on('playbackEnd', (track) => {
43
- this.emit('playbackEnd', track);
42
+ this.emit('MusicEnd', track);
44
43
  });
45
44
 
46
45
  this.streamer.on('progress', (data) => {
47
- this.emit('progress', data);
46
+ this.emit('Progress', data);
48
47
  });
49
48
 
50
49
  this.streamer.on('error', (error) => {
51
- this.emit('error', error);
50
+ this.emit('Error', error);
52
51
  });
53
52
 
54
53
  this.streamer.on('streamStopped', () => {
55
- this.emit('streamStopped');
54
+ this.emit('StreamEnded');
56
55
  });
57
-
58
56
  } catch (error) {
59
57
  console.error('Failed to initialize Icecast streamer:', error);
60
58
  throw error;
@@ -71,86 +69,57 @@ class MusicClass extends EventEmitter {
71
69
  });
72
70
  }
73
71
 
74
- async play(query, metadata = {}) {
72
+ async play(input, metadata = {}) {
75
73
  try {
76
74
  if (!this.streamer) {
77
75
  throw new Error('Streamer not initialized');
78
76
  }
79
77
 
80
- let trackInfo;
81
- let source = 'direct';
82
-
83
- if (this.isValidUrl(query)) {
84
- if (query.includes('youtube.com') || query.includes('youtu.be')) {
85
- try {
86
- trackInfo = await YouTubeExtractor.getStreamUrl(query);
87
- } catch (error) {
88
- return {
89
- success: false,
90
- error: error.message.includes('yt-dlp')
91
- ? 'Missing dependency: yt-dlp'
92
- : `YouTube error: ${error.message}`
93
- };
94
- }
95
- source = 'youtube';
96
- } else {
97
- trackInfo = {
98
- url: query,
99
- title: metadata.title || 'Unknown Track',
100
- duration: metadata.duration || 0,
101
- thumbnail: metadata.thumbnail || null
102
- };
78
+ let result;
79
+ if (this.isValidUrl(input)) {
80
+ if (input.includes('youtube.com') || input.includes('youtu.be')) {
81
+ result = await this.queue.addFromYouTube(input, metadata);
103
82
  }
104
83
  } else {
105
- trackInfo = await YouTubeExtractor.searchTrack(query);
106
- source = 'youtube';
84
+ result = await this.queue.addFromYouTube(input, metadata);
107
85
  }
108
86
 
109
- if (!trackInfo) {
87
+ if (!result.added) {
110
88
  return {
111
89
  success: false,
112
- error: 'Could not find or play the requested track'
90
+ error: result.reason === 'duplicate' ? 'Track already in queue' : 'Failed to add track'
113
91
  };
114
92
  }
115
93
 
116
- const track = new Track({
117
- url: trackInfo.url,
118
- title: trackInfo.title || metadata.title || 'Unknown Track',
119
- duration: trackInfo.duration || metadata.duration || 0,
120
- requester: metadata.requester || 'System',
121
- requesterId: metadata.requesterId || 'system',
122
- thumbnail: trackInfo.thumbnail || metadata.thumbnail,
123
- source: source
124
- });
125
-
126
- const result = await this.queue.add(track);
94
+ const track = this.queue.getQueue()[result.position - 1] ||
95
+ (result.isNowPlaying ? this.streamer.currentTrack : null);
127
96
 
128
97
  return {
129
98
  success: true,
130
- track: {
99
+ track: track ? {
131
100
  title: track.title,
132
101
  duration: track.duration,
133
102
  formattedDuration: track.getFormattedDuration(),
134
103
  requester: track.requester,
135
104
  thumbnail: track.thumbnail
136
- },
105
+ } : null,
137
106
  position: result.position,
138
107
  isNowPlaying: result.isNowPlaying || false
139
108
  };
140
-
141
109
  } catch (error) {
142
110
  console.error('Error playing track:', error);
143
111
  return {
144
112
  success: false,
145
- error: error.message
113
+ error: error.message.includes('yt-dlp')
114
+ ? 'Missing dependency: yt-dlp or FFmpeg'
115
+ : error.message
146
116
  };
147
117
  }
148
118
  }
149
119
 
150
120
  async skip() {
151
121
  try {
152
- const result = await this.queue.skip();
153
-
122
+ const success = await this.queue.skip();
154
123
  let upcoming = null;
155
124
  if (this.queue.queue.length > 0) {
156
125
  const nextTrack = this.queue.queue[0];
@@ -160,25 +129,16 @@ class MusicClass extends EventEmitter {
160
129
  formattedDuration: nextTrack.getFormattedDuration()
161
130
  };
162
131
  }
163
-
164
- return {
165
- success: result,
166
- upcoming: upcoming
167
- };
132
+ return { success, upcoming };
168
133
  } catch (error) {
169
134
  console.error('Error skipping track:', error);
170
- return {
171
- success: false,
172
- error: error.message
173
- };
135
+ return { success: false, error: error.message };
174
136
  }
175
137
  }
176
138
 
177
139
  getNowPlaying() {
178
140
  const np = this.streamer.getNowPlaying();
179
- if (!np || !np.track) {
180
- return null;
181
- }
141
+ if (!np || !np.track) return null;
182
142
 
183
143
  return {
184
144
  track: np.track,
@@ -193,11 +153,12 @@ class MusicClass extends EventEmitter {
193
153
  }
194
154
 
195
155
  getQueue() {
156
+ const queue = this.queue?.getQueue() || [];
196
157
  return {
197
- queue: this.queue?.getQueue(),
198
- upcoming: this.queue?.getQueue().slice(0, 10),
199
- loopMode: this.queue.loopMode,
200
- length: this.queue.queue.length
158
+ queue,
159
+ upcoming: queue.slice(0, 10),
160
+ loopMode: this.queue?.loopMode || 'off',
161
+ length: queue.length
201
162
  };
202
163
  }
203
164
 
@@ -206,30 +167,18 @@ class MusicClass extends EventEmitter {
206
167
  const currentIndex = modes.indexOf(this.queue.loopMode);
207
168
  const nextIndex = (currentIndex + 1) % modes.length;
208
169
  this.queue.loopMode = modes[nextIndex];
209
-
210
- return {
211
- success: true,
212
- newMode: this.queue.loopMode
213
- };
170
+ return { success: true, newMode: this.queue.loopMode };
214
171
  }
215
172
 
216
173
  shuffle() {
217
- if (this.queue.queue.length === 0) {
218
- return {
219
- success: false,
220
- error: 'Queue is empty'
221
- };
174
+ if (!this.queue || this.queue.queue.length === 0) {
175
+ return { success: false, error: 'Queue is empty' };
222
176
  }
223
-
224
177
  for (let i = this.queue.queue.length - 1; i > 0; i--) {
225
178
  const j = Math.floor(Math.random() * (i + 1));
226
179
  [this.queue.queue[i], this.queue.queue[j]] = [this.queue.queue[j], this.queue.queue[i]];
227
180
  }
228
-
229
- return {
230
- success: true,
231
- queue: this.getQueue()
232
- };
181
+ return { success: true, queue: this.getQueue() };
233
182
  }
234
183
 
235
184
  removeFromQueue(index) {
@@ -255,11 +204,9 @@ class MusicClass extends EventEmitter {
255
204
 
256
205
  formatDuration(seconds) {
257
206
  if (!seconds) return '0:00';
258
-
259
207
  const hours = Math.floor(seconds / 3600);
260
208
  const minutes = Math.floor((seconds % 3600) / 60);
261
209
  const secondsRemaining = Math.floor(seconds % 60);
262
-
263
210
  if (hours > 0) {
264
211
  return `${hours}:${minutes.toString().padStart(2, '0')}:${secondsRemaining.toString().padStart(2, '0')}`;
265
212
  }
@@ -275,12 +222,26 @@ class MusicClass extends EventEmitter {
275
222
  };
276
223
  }
277
224
 
225
+ _startWebServer() {
226
+ try {
227
+ this.webServer = new IcecastServer({
228
+ port: this.config.serverPort,
229
+ publicStreamUrl: this.streamer.publicStreamUrl,
230
+ logger: this.bot.utils?.logger || console
231
+ });
232
+ this.webServer.start();
233
+ return true;
234
+ } catch (error) {
235
+ console.error('Failed to start web server:', error);
236
+ return false;
237
+ }
238
+ }
239
+
278
240
  destroy() {
279
241
  try {
280
242
  if (this.streamer) {
281
243
  this.streamer.stop();
282
244
  }
283
-
284
245
  return true;
285
246
  } catch (error) {
286
247
  console.error('Error destroying music class:', error);
@@ -1,6 +1,9 @@
1
1
  const express = require('express');
2
2
  const { spawn } = require('child_process');
3
3
  const EventEmitter = require('events');
4
+ const fs = require('fs').promises;
5
+ const os = require('os');
6
+ const path = require('path');
4
7
 
5
8
  class IcecastStreamer extends EventEmitter {
6
9
  constructor(config = {}) {
@@ -12,25 +15,30 @@ class IcecastStreamer extends EventEmitter {
12
15
  sourcePassword: config.sourcePassword || 'hackme',
13
16
  fallbackUrl: config.fallbackUrl || '',
14
17
  fallbackEnabled: config.fallbackEnabled || false,
15
- audioFormat: config.audioFormat || 'mp3',
16
- audioBitrate: config.audioBitrate || '192k',
18
+ audioFormat: config.audioFormat || 'webm',
19
+ audioBitrate: config.audioBitrate || '128k',
17
20
  audioSampleRate: config.audioSampleRate || 48000,
18
21
  audioChannels: config.audioChannels || 2,
19
- contentType: config.contentType || 'audio/mpeg'
22
+ contentType: config.contentType || 'audio/webm'
20
23
  };
21
24
 
22
25
  this.ffmpegProcess = null;
23
26
  this.fallbackProcess = null;
24
- this.isStreaming = false; // Only for actual music
25
- this.isFallbackActive = false; // Separate flag for fallback
27
+ this.isStreaming = false;
28
+ this.isFallbackActive = false;
29
+ this.songEmitted = false;
30
+ this.fallbackEmitted = false;
26
31
  this.currentTrack = null;
32
+ this.tempFilesToDelete = new Set();
27
33
 
28
34
  this.icecastUrl = `icecast://source:${this.config.sourcePassword}@${this.config.server}:${this.config.port}${this.config.mount}`;
29
35
  this.publicStreamUrl = `http://${this.config.server}:${this.config.port}${this.config.mount}`;
30
36
  }
31
37
 
32
38
  getFFmpegArgs(url) {
33
- return [
39
+ const args = [];
40
+
41
+ args.push(
34
42
  '-re',
35
43
  '-i', url,
36
44
  '-f', this.config.audioFormat,
@@ -40,12 +48,18 @@ class IcecastStreamer extends EventEmitter {
40
48
  '-ac', this.config.audioChannels.toString(),
41
49
  '-b:a', this.config.audioBitrate,
42
50
  this.icecastUrl
43
- ];
51
+ );
52
+
53
+ return args;
44
54
  }
45
55
 
46
56
  getFallbackFFmpegArgs() {
47
57
  return [
48
58
  '-re',
59
+ '-reconnect', '1',
60
+ '-reconnect_at_eof', '1',
61
+ '-reconnect_streamed', '1',
62
+ '-reconnect_delay_max', '5',
49
63
  '-i', this.config.fallbackUrl,
50
64
  '-f', this.config.audioFormat,
51
65
  '-content_type', this.config.contentType,
@@ -77,9 +91,14 @@ class IcecastStreamer extends EventEmitter {
77
91
  requesterId: metadata.requesterId || 'system',
78
92
  thumbnail: metadata.thumbnail,
79
93
  source: metadata.source || 'stream',
80
- startedAt: Date.now()
94
+ startedAt: Date.now(),
95
+ isTempFile: metadata.isTempFile || false
81
96
  });
82
97
 
98
+ if (metadata.isTempFile) {
99
+ this.tempFilesToDelete.add(url);
100
+ }
101
+
83
102
  this.isStreaming = true;
84
103
  this.isFallbackActive = false;
85
104
 
@@ -107,12 +126,13 @@ class IcecastStreamer extends EventEmitter {
107
126
  this.emit('progress', {
108
127
  position: this.currentTrack.position,
109
128
  duration: this.currentTrack.duration,
110
- progress: this.currentTrack.duration > 0 ?
129
+ percentage: this.currentTrack.duration > 0 ?
111
130
  (this.currentTrack.position / this.currentTrack.duration) * 100 : 0
112
131
  });
113
132
  }
114
133
 
115
- if (message.includes('Stream mapping') || message.includes('Output')) {
134
+ if (!this.songEmitted && (message.includes('Stream mapping') || message.includes('Output'))) {
135
+ this.songEmitted = true;
116
136
  this.emit('playbackStart', this.currentTrack);
117
137
  resolve();
118
138
  }
@@ -123,21 +143,38 @@ class IcecastStreamer extends EventEmitter {
123
143
  }
124
144
  });
125
145
 
126
- this.ffmpegProcess.on('close', (code) => {
146
+ this.ffmpegProcess.on('close', async (code) => {
127
147
  const endedTrack = this.currentTrack;
128
148
 
129
149
  this.isStreaming = false;
130
150
  this.currentTrack = null;
131
151
  this.ffmpegProcess = null;
152
+ this.songEmitted = false;
153
+
154
+ if (endedTrack?.url && this.tempFilesToDelete.has(endedTrack.url)) {
155
+ try {
156
+ await fs.unlink(endedTrack.url);
157
+ this.tempFilesToDelete.delete(endedTrack.url);
158
+ } catch { }
159
+ }
132
160
 
133
161
  this.emit('playbackEnd', endedTrack);
134
162
  this.emit('streamEnd', { code, track: endedTrack });
135
163
  });
136
164
 
137
- this.ffmpegProcess.on('error', (err) => {
165
+ this.ffmpegProcess.on('error', async (err) => {
138
166
  console.error('FFmpeg process error:', err);
139
167
  this.isStreaming = false;
140
168
  this.currentTrack = null;
169
+ this.songEmitted = false;
170
+
171
+ if (this.currentTrack?.url && this.tempFilesToDelete.has(this.currentTrack.url)) {
172
+ try {
173
+ await fs.unlink(this.currentTrack.url);
174
+ this.tempFilesToDelete.delete(this.currentTrack.url);
175
+ } catch { }
176
+ }
177
+
141
178
  reject(err);
142
179
  this.emit('error', err);
143
180
  });
@@ -174,7 +211,8 @@ class IcecastStreamer extends EventEmitter {
174
211
  this.fallbackProcess.stderr.on('data', (data) => {
175
212
  const message = data.toString();
176
213
 
177
- if (message.includes('Stream mapping') || message.includes('Output')) {
214
+ if (!this.fallbackEmitted && (message.includes('Stream mapping') || message.includes('Output'))) {
215
+ this.fallbackEmitted = true;
178
216
  this.emit('fallbackStart', this.currentTrack);
179
217
  }
180
218
 
@@ -187,13 +225,14 @@ class IcecastStreamer extends EventEmitter {
187
225
  this.isFallbackActive = false;
188
226
  this.currentTrack = null;
189
227
  this.fallbackProcess = null;
190
-
228
+ this.fallbackEmitted = false;
191
229
  this.emit('fallbackEnd', { code });
192
230
  });
193
231
 
194
232
  this.fallbackProcess.on('error', (err) => {
195
233
  console.error('Fallback FFmpeg process error:', err);
196
234
  this.isFallbackActive = false;
235
+ this.fallbackEmitted = false;
197
236
  this.currentTrack = null;
198
237
  });
199
238
 
@@ -206,6 +245,7 @@ class IcecastStreamer extends EventEmitter {
206
245
  this.fallbackProcess = null;
207
246
  this.isFallbackActive = false;
208
247
  this.currentTrack = null;
248
+ this.fallbackEmitted = false;
209
249
  this.emit('fallbackStopped');
210
250
  return true;
211
251
  }
@@ -217,6 +257,7 @@ class IcecastStreamer extends EventEmitter {
217
257
  this.ffmpegProcess.kill('SIGKILL');
218
258
  this.ffmpegProcess = null;
219
259
  this.isStreaming = false;
260
+ this.songEmitted = false;
220
261
  this.emit('streamStopped');
221
262
  }
222
263
 
@@ -286,15 +327,14 @@ class Track {
286
327
  this.thumbnail = data.thumbnail;
287
328
  this.source = data.source;
288
329
  this.isFallback = data.isFallback || false;
330
+ this.isTempFile = data.isTempFile || false;
289
331
  }
290
332
 
291
333
  getFormattedDuration() {
292
334
  if (!this.duration) return 'Unknown';
293
-
294
335
  const hours = Math.floor(this.duration / 3600);
295
336
  const minutes = Math.floor((this.duration % 3600) / 60);
296
337
  const seconds = Math.floor(this.duration % 60);
297
-
298
338
  if (hours > 0) {
299
339
  return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
300
340
  }
@@ -312,12 +352,9 @@ class IcecastQueue {
312
352
  this.history = [];
313
353
  this.maxHistory = config.maxHistory || 50;
314
354
  this.skipRequested = false;
315
-
316
355
  this.fallbackEnabled = config.fallbackEnabled || false;
317
356
  this.fallbackUrl = config.fallbackUrl || '';
318
-
319
357
  this.setupEventListeners();
320
-
321
358
  this.checkAndStartFallback();
322
359
  }
323
360
 
@@ -326,18 +363,15 @@ class IcecastQueue {
326
363
  if (track && !track.isFallback) {
327
364
  this.addToHistory(track, true);
328
365
  }
329
-
330
366
  if (this.skipRequested) {
331
367
  this.skipRequested = false;
332
368
  return;
333
369
  }
334
-
335
370
  if (this.loopMode === 'track' && track && !track.isFallback) {
336
371
  this.queue.unshift(new Track({ ...track }));
337
372
  } else if (this.loopMode === 'queue' && track && !track.isFallback) {
338
373
  this.queue.push(new Track({ ...track }));
339
374
  }
340
-
341
375
  setTimeout(() => this.playNext(), 300);
342
376
  });
343
377
 
@@ -361,7 +395,6 @@ class IcecastQueue {
361
395
  !this.streamer.isFallbackActive &&
362
396
  this.fallbackEnabled &&
363
397
  this.fallbackUrl) {
364
-
365
398
  setTimeout(() => {
366
399
  if (this.queue.length === 0 &&
367
400
  !this.streamer.isStreaming &&
@@ -377,15 +410,12 @@ class IcecastQueue {
377
410
  if (!isNewTrack) {
378
411
  return { position: -1, added: false, reason: 'duplicate' };
379
412
  }
380
-
381
413
  this.queue.push(track);
382
414
  const position = this.queue.length;
383
-
384
415
  if (!this.streamer.isStreaming && this.queue.length === 1) {
385
416
  await this.playNext();
386
417
  return { position: 1, added: true, isNowPlaying: true };
387
418
  }
388
-
389
419
  return { position, added: true, isNowPlaying: false };
390
420
  }
391
421
 
@@ -393,46 +423,51 @@ class IcecastQueue {
393
423
  if (this.currentTrack && !this.currentTrack.isFallback) {
394
424
  this.queue.unshift(new Track({ ...this.currentTrack }));
395
425
  }
396
-
397
426
  this.queue.unshift(track);
398
427
  await this.skip();
399
428
  return { position: 1, added: true, isNowPlaying: true };
400
429
  }
401
430
 
402
- async playNext() {
403
- if (this.isProcessing) {
404
- return false;
431
+ async addFromYouTube(input, metadata) {
432
+ let trackData;
433
+ if (input.startsWith('http')) {
434
+ trackData = await YouTubeExtractor.downloadAudioToTempFile(input);
435
+ } else {
436
+ trackData = await YouTubeExtractor.searchAndDownload(input);
405
437
  }
438
+ trackData.requester = metadata.requester
439
+ trackData.requesterId = metadata.requesterId
406
440
 
407
- this.isProcessing = true;
441
+ const track = new Track(trackData);
442
+ return await this.add(track);
443
+ }
408
444
 
445
+ async playNext() {
446
+ if (this.isProcessing) return false;
447
+ this.isProcessing = true;
409
448
  try {
410
449
  if (this.streamer.isFallbackActive) {
411
450
  this.streamer.stopFallback();
412
451
  await this.delay(200);
413
452
  }
414
-
415
453
  if (this.queue.length === 0) {
416
454
  this.checkAndStartFallback();
417
455
  return false;
418
456
  }
419
-
420
457
  const nextTrack = this.queue.shift();
421
-
422
458
  if (!nextTrack?.url) {
423
459
  setTimeout(() => this.playNext(), 1000);
424
460
  return false;
425
461
  }
426
-
427
462
  await this.streamer.streamToIcecast(nextTrack.url, {
428
463
  title: nextTrack.title,
429
464
  duration: nextTrack.duration,
430
465
  requester: nextTrack.requester,
431
466
  requesterId: nextTrack.requesterId,
432
467
  thumbnail: nextTrack.thumbnail,
433
- source: nextTrack.source
468
+ source: nextTrack.source,
469
+ isTempFile: nextTrack.isTempFile
434
470
  });
435
-
436
471
  return true;
437
472
  } catch (error) {
438
473
  console.error('Error playing next track:', error);
@@ -447,21 +482,15 @@ class IcecastQueue {
447
482
  if (!this.streamer.isStreaming && this.streamer.isFallbackActive) {
448
483
  return false;
449
484
  }
450
-
451
485
  this.skipRequested = true;
452
486
  this.streamer.stop();
453
-
454
487
  await this.delay(100);
455
488
  await this.playNext();
456
-
457
489
  return true;
458
490
  }
459
491
 
460
492
  remove(position) {
461
- if (position < 1 || position > this.queue.length) {
462
- return null;
463
- }
464
-
493
+ if (position < 1 || position > this.queue.length) return null;
465
494
  return this.queue.splice(position - 1, 1)[0];
466
495
  }
467
496
 
@@ -470,7 +499,6 @@ class IcecastQueue {
470
499
  toIndex < 0 || toIndex >= this.queue.length) {
471
500
  return false;
472
501
  }
473
-
474
502
  const track = this.queue.splice(fromIndex, 1)[0];
475
503
  this.queue.splice(toIndex, 0, track);
476
504
  return true;
@@ -485,24 +513,19 @@ class IcecastQueue {
485
513
  clear() {
486
514
  const cleared = [...this.queue];
487
515
  this.queue = [];
488
-
489
516
  this.checkAndStartFallback();
490
-
491
517
  return cleared;
492
518
  }
493
519
 
494
520
  addToHistory(track, success = true) {
495
521
  if (track.isFallback) return;
496
-
497
522
  const historyEntry = {
498
523
  ...track,
499
524
  playedAt: Date.now(),
500
525
  success,
501
526
  endedAt: Date.now()
502
527
  };
503
-
504
528
  this.history.unshift(historyEntry);
505
-
506
529
  if (this.history.length > this.maxHistory) {
507
530
  this.history.pop();
508
531
  }
@@ -542,64 +565,102 @@ class IcecastQueue {
542
565
  }
543
566
 
544
567
  class YouTubeExtractor {
545
- static async getStreamUrl(youtubeUrl) {
546
- return new Promise((resolve) => {
547
- const ytdlp = spawn('yt-dlp', [
568
+ static async downloadAudioToTempFile(youtubeUrl) {
569
+ return new Promise((resolve, reject) => {
570
+ const tempPath = path.join(os.tmpdir(), `yt-${Date.now()}-${Math.random().toString(36).substring(2, 10)}.webm`);
571
+
572
+ // First, get metadata using --print (like your original getStreamUrl)
573
+ const metaProc = spawn('yt-dlp', [
548
574
  youtubeUrl,
549
575
  '--print', '%(url)s',
550
576
  '--print', '%(title)s',
551
577
  '--print', '%(duration)s',
552
578
  '--print', '%(thumbnail)s',
553
- '-f', 'bestaudio[ext=m4a]/bestaudio',
554
579
  '--no-playlist',
555
580
  '--no-check-certificates',
556
581
  '--quiet'
557
- ], {
558
- timeout: 15000
559
- });
582
+ ], { timeout: 15000 });
560
583
 
561
- let data = '';
562
- ytdlp.stdout.on('data', (chunk) => data += chunk.toString());
563
- ytdlp.stderr.on('data', () => { });
584
+ let metaData = '';
585
+ metaProc.stdout.on('data', d => metaData += d.toString());
586
+ let metaErr = '';
587
+ metaProc.stderr.on('data', d => metaErr += d.toString());
564
588
 
565
- ytdlp.on('error', (err) => {
589
+ metaProc.on('error', (err) => {
566
590
  if (err.code === 'ENOENT') {
567
- reject(new Error('yt-dlp not found. Install with: pip install yt-dlp'));
591
+ reject(new Error('yt-dlp not found. Install with: pip install "yt-dlp[default]"'));
568
592
  } else {
569
593
  reject(err);
570
594
  }
571
595
  });
572
596
 
573
- ytdlp.on('timeout', () => {
574
- ytdlp.kill('SIGKILL');
575
- reject(new Error('YouTube fetch timed out (15s)'));
597
+ metaProc.on('timeout', () => {
598
+ metaProc.kill('SIGKILL');
599
+ reject(new Error('Metadata fetch timed out (15s)'));
576
600
  });
577
601
 
578
- ytdlp.on('close', (code) => {
602
+ metaProc.on('close', async (code) => {
579
603
  if (code !== 0) {
580
- resolve(null);
604
+ reject(new Error(`Failed to fetch YouTube metadata: ${metaErr || 'unknown error'}`));
605
+ return;
606
+ }
607
+
608
+ const lines = metaData.trim().split('\n');
609
+ if (lines.length < 4) {
610
+ reject(new Error('Invalid metadata response from yt-dlp'));
581
611
  return;
582
612
  }
583
613
 
584
- const lines = data.trim().split('\n');
614
+ const [, title, durStr, thumbnail] = lines;
615
+ const duration = parseInt(durStr) || 0;
616
+
617
+ // Now download the actual audio to temp file
618
+ const ytdlp = spawn('yt-dlp', [
619
+ youtubeUrl,
620
+ '-f', 'ba',
621
+ '--output', tempPath,
622
+ '--no-playlist',
623
+ '--no-check-certificates',
624
+ '--quiet'
625
+ ], { timeout: 30000 });
626
+
627
+ let stderr = '';
628
+ ytdlp.stderr.on('data', (chunk) => stderr += chunk.toString());
629
+
630
+ ytdlp.on('error', (err) => {
631
+ try { fs.unlink(tempPath); } catch { }
632
+ reject(err);
633
+ });
634
+
635
+ ytdlp.on('timeout', () => {
636
+ ytdlp.kill('SIGKILL');
637
+ try { fs.unlink(tempPath); } catch { }
638
+ reject(new Error('YouTube download timed out (30s)'));
639
+ });
640
+
641
+ ytdlp.on('close', async (dlCode) => {
642
+ if (dlCode !== 0) {
643
+ try { await fs.unlink(tempPath); } catch { }
644
+ reject(new Error(`yt-dlp download failed: ${stderr}`));
645
+ return;
646
+ }
585
647
 
586
- if (lines.length >= 4 && lines[0].startsWith('http')) {
587
648
  resolve({
588
- url: lines[0],
589
- title: lines[1],
590
- duration: parseInt(lines[2]) || 0,
591
- thumbnail: lines[3] || null,
592
- source: 'youtube'
649
+ url: tempPath,
650
+ title: title || 'Unknown Track',
651
+ duration: duration,
652
+ thumbnail: thumbnail || null,
653
+ source: 'youtube',
654
+ isTempFile: true
593
655
  });
594
- } else {
595
- resolve(null);
596
- }
656
+ });
597
657
  });
598
658
  });
599
659
  }
600
660
 
601
- static async searchTrack(query) {
602
- return this.getStreamUrl(`ytsearch1:${query}`);
661
+ static async searchAndDownload(query) {
662
+ const searchUrl = `ytsearch1:${query}`;
663
+ return await this.downloadAudioToTempFile(searchUrl);
603
664
  }
604
665
  }
605
666
 
@@ -607,14 +668,13 @@ class IcecastServer {
607
668
  constructor(config = {}) {
608
669
  this.app = express();
609
670
  this.port = config.port || 3000;
610
- this.config = config
611
- this.logger = config.logger
671
+ this.config = config;
672
+ this.logger = config.logger || console;
612
673
  this.setupExpress();
613
674
  }
614
675
 
615
676
  setupExpress() {
616
677
  this.app.use(express.json());
617
-
618
678
  this.app.get('/stream', (req, res) => {
619
679
  res.redirect(this.config.publicStreamUrl);
620
680
  });
@@ -55,7 +55,6 @@ class ConnectionManager extends EventEmitter {
55
55
  [WebSocketConstants.HEADERS.ROOM_ID]: roomId
56
56
  },
57
57
  handshakeTimeout: WebSocketConstants.DEFAULT_HANDSHAKE_TIMEOUT,
58
- autoPong: true
59
58
  });
60
59
 
61
60
  this._setupWebSocketHandlers();
@@ -101,7 +100,7 @@ class ConnectionManager extends EventEmitter {
101
100
  this.reconnecting = false;
102
101
  this._clearReconnectTimeout();
103
102
 
104
- this.emit('Connected');
103
+ this.emit('connected');
105
104
  this.logger.info('ConnectionManager', 'WebSocket connection established');
106
105
  }
107
106
 
@@ -111,7 +110,7 @@ class ConnectionManager extends EventEmitter {
111
110
  this.reconnecting = false;
112
111
 
113
112
  const reasonStr = reason?.toString('utf8') || `Binary reason (length: ${reason?.length || 0})`;
114
- this.emit('Disconnected', { code, reason: reasonStr });
113
+ this.emit('disconnected', { code, reason: reasonStr });
115
114
 
116
115
  this.logger.warn('ConnectionManager', 'Connection closed', {
117
116
  code,
@@ -184,7 +183,7 @@ class ConnectionManager extends EventEmitter {
184
183
  }
185
184
 
186
185
  this.connected = false;
187
- this.emit('Disconnected', { code, reason, IsManual: true });
186
+ this.emit('disconnected', { code, reason, IsManual: true });
188
187
  }
189
188
 
190
189
  send(data) {
@@ -389,11 +389,20 @@ interface EventMap {
389
389
 
390
390
  // Connections
391
391
  error: [data: any]
392
- Connected: []
393
- Disconnected: [data: DisconnectedData]
392
+ connected: []
393
+ disconnected: [data: DisconnectedData]
394
394
  }
395
395
 
396
-
396
+ /**
397
+ * Music event types
398
+ */
399
+ interface MusicEventMap {
400
+ MusicStart: [track: Track];
401
+ MusicEnd: [track: Track];
402
+ Progress: [data: { position: number, duration: number, percentage: number }];
403
+ MusicError: [error: Error];
404
+ StreamEnd: [];
405
+ }
397
406
 
398
407
  interface CachedUser {
399
408
  position: Position;
@@ -1148,19 +1157,6 @@ interface ShuffleResult {
1148
1157
  error?: string;
1149
1158
  }
1150
1159
 
1151
- /**
1152
- * Music event types
1153
- */
1154
- interface MusicEventMap {
1155
- 'playbackStart': [track: Track];
1156
- 'playbackEnd': [track: Track];
1157
- 'progress': [data: { position: number, duration: number, progress: number }];
1158
- 'error': [error: Error];
1159
- 'streamStopped': [];
1160
- 'Ready': [];
1161
- 'ready': [];
1162
- }
1163
-
1164
1160
  /**
1165
1161
  * Logger configuration - controls how bot messages appear in console
1166
1162
  */