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.
|
|
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.
|
|
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('
|
|
38
|
+
this.emit('MusicStart', track);
|
|
40
39
|
});
|
|
41
40
|
|
|
42
41
|
this.streamer.on('playbackEnd', (track) => {
|
|
43
|
-
this.emit('
|
|
42
|
+
this.emit('MusicEnd', track);
|
|
44
43
|
});
|
|
45
44
|
|
|
46
45
|
this.streamer.on('progress', (data) => {
|
|
47
|
-
this.emit('
|
|
46
|
+
this.emit('Progress', data);
|
|
48
47
|
});
|
|
49
48
|
|
|
50
49
|
this.streamer.on('error', (error) => {
|
|
51
|
-
this.emit('
|
|
50
|
+
this.emit('Error', error);
|
|
52
51
|
});
|
|
53
52
|
|
|
54
53
|
this.streamer.on('streamStopped', () => {
|
|
55
|
-
this.emit('
|
|
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(
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
106
|
-
source = 'youtube';
|
|
84
|
+
result = await this.queue.addFromYouTube(input, metadata);
|
|
107
85
|
}
|
|
108
86
|
|
|
109
|
-
if (!
|
|
87
|
+
if (!result.added) {
|
|
110
88
|
return {
|
|
111
89
|
success: false,
|
|
112
|
-
error: '
|
|
90
|
+
error: result.reason === 'duplicate' ? 'Track already in queue' : 'Failed to add track'
|
|
113
91
|
};
|
|
114
92
|
}
|
|
115
93
|
|
|
116
|
-
const track =
|
|
117
|
-
|
|
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
|
|
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
|
|
198
|
-
upcoming:
|
|
199
|
-
loopMode: this.queue
|
|
200
|
-
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 || '
|
|
16
|
-
audioBitrate: config.audioBitrate || '
|
|
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/
|
|
22
|
+
contentType: config.contentType || 'audio/webm'
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
this.ffmpegProcess = null;
|
|
23
26
|
this.fallbackProcess = null;
|
|
24
|
-
this.isStreaming = false;
|
|
25
|
-
this.isFallbackActive = false;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
|
546
|
-
return new Promise((resolve) => {
|
|
547
|
-
const
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
reject(new Error('
|
|
597
|
+
metaProc.on('timeout', () => {
|
|
598
|
+
metaProc.kill('SIGKILL');
|
|
599
|
+
reject(new Error('Metadata fetch timed out (15s)'));
|
|
576
600
|
});
|
|
577
601
|
|
|
578
|
-
|
|
602
|
+
metaProc.on('close', async (code) => {
|
|
579
603
|
if (code !== 0) {
|
|
580
|
-
|
|
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
|
|
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:
|
|
589
|
-
title:
|
|
590
|
-
duration:
|
|
591
|
-
thumbnail:
|
|
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
|
-
}
|
|
595
|
-
resolve(null);
|
|
596
|
-
}
|
|
656
|
+
});
|
|
597
657
|
});
|
|
598
658
|
});
|
|
599
659
|
}
|
|
600
660
|
|
|
601
|
-
static async
|
|
602
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
186
|
+
this.emit('disconnected', { code, reason, IsManual: true });
|
|
188
187
|
}
|
|
189
188
|
|
|
190
189
|
send(data) {
|
package/typings/index.d.ts
CHANGED
|
@@ -389,11 +389,20 @@ interface EventMap {
|
|
|
389
389
|
|
|
390
390
|
// Connections
|
|
391
391
|
error: [data: any]
|
|
392
|
-
|
|
393
|
-
|
|
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
|
*/
|