streamify-audio 2.1.14 â 2.1.16
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 +3 -2
- package/src/discord/Manager.js +1 -1
- package/src/discord/Player.js +62 -24
- package/src/discord/Stream.js +63 -25
- package/src/filters/ffmpeg.js +51 -26
- package/src/providers/youtube.js +23 -4
- package/src/server.js +5 -5
- package/src/utils/logger.js +34 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamify-audio",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.16",
|
|
4
4
|
"description": "Dual-mode audio library: HTTP streaming proxy + Discord player (Lavalink alternative). Supports YouTube, Spotify, SoundCloud with audio filters.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"author": "Lucas",
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"express": "^4.18.2"
|
|
32
|
+
"express": "^4.18.2",
|
|
33
|
+
"opusscript": "^0.0.8"
|
|
33
34
|
},
|
|
34
35
|
"optionalDependencies": {
|
|
35
36
|
"@discordjs/opus": "^0.9.0",
|
package/src/discord/Manager.js
CHANGED
|
@@ -217,7 +217,7 @@ class Manager extends EventEmitter {
|
|
|
217
217
|
if (!this._isProviderEnabled('youtube')) {
|
|
218
218
|
throw new Error('YouTube provider is disabled');
|
|
219
219
|
}
|
|
220
|
-
result = await youtube.search(query, limit, this.config);
|
|
220
|
+
result = await youtube.search(query, limit, this.config, options);
|
|
221
221
|
break;
|
|
222
222
|
|
|
223
223
|
case 'spotify':
|
package/src/discord/Player.js
CHANGED
|
@@ -218,6 +218,10 @@ class Player extends EventEmitter {
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
this.audioPlayer.on('error', (error) => {
|
|
221
|
+
if (this._manualSkip || this._changingStream) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
221
225
|
if (error.message === 'Premature close' || error.message.includes('EPIPE')) {
|
|
222
226
|
return;
|
|
223
227
|
}
|
|
@@ -234,7 +238,8 @@ class Player extends EventEmitter {
|
|
|
234
238
|
|
|
235
239
|
const next = this.queue.shift();
|
|
236
240
|
if (next) {
|
|
237
|
-
|
|
241
|
+
// Use setImmediate to break recursion stack on consecutive failures
|
|
242
|
+
setImmediate(() => this._playTrack(next));
|
|
238
243
|
} else {
|
|
239
244
|
this.emit('queueEnd');
|
|
240
245
|
}
|
|
@@ -309,7 +314,7 @@ class Player extends EventEmitter {
|
|
|
309
314
|
};
|
|
310
315
|
|
|
311
316
|
if (startPosition === 0 && this._prefetchedTrack?.id === track.id && this._prefetchedStream) {
|
|
312
|
-
log.
|
|
317
|
+
log.debug('PLAYER', `Using prefetched stream for ${track.id}`);
|
|
313
318
|
this.stream = this._prefetchedStream;
|
|
314
319
|
this._prefetchedStream = null;
|
|
315
320
|
this._prefetchedTrack = null;
|
|
@@ -332,10 +337,15 @@ class Player extends EventEmitter {
|
|
|
332
337
|
} catch (error) {
|
|
333
338
|
log.error('PLAYER', `Failed to play track: ${error.message}`);
|
|
334
339
|
this.emit('trackError', track, error);
|
|
340
|
+
|
|
341
|
+
// If manual skip is active, we just return so the skip function handles it
|
|
342
|
+
if (this._manualSkip) return;
|
|
335
343
|
|
|
336
344
|
const next = this.queue.shift();
|
|
337
345
|
if (next) {
|
|
338
|
-
|
|
346
|
+
// Use setImmediate to break recursion stack on consecutive failures
|
|
347
|
+
setImmediate(() => this._playTrack(next));
|
|
348
|
+
return;
|
|
339
349
|
} else {
|
|
340
350
|
this.emit('queueEnd');
|
|
341
351
|
}
|
|
@@ -351,7 +361,7 @@ class Player extends EventEmitter {
|
|
|
351
361
|
this._prefetching = true;
|
|
352
362
|
this._clearPrefetch();
|
|
353
363
|
|
|
354
|
-
log.
|
|
364
|
+
log.debug('PLAYER', `Prefetching: ${nextTrack.title} (${nextTrack.id})`);
|
|
355
365
|
|
|
356
366
|
try {
|
|
357
367
|
const filtersWithVolume = {
|
|
@@ -363,7 +373,7 @@ class Player extends EventEmitter {
|
|
|
363
373
|
this._prefetchedStream = createStream(nextTrack, filtersWithVolume, this.config);
|
|
364
374
|
await this._prefetchedStream.create();
|
|
365
375
|
|
|
366
|
-
log.
|
|
376
|
+
log.debug('PLAYER', `Prefetch ready: ${nextTrack.id}`);
|
|
367
377
|
} catch (error) {
|
|
368
378
|
log.debug('PLAYER', `Prefetch failed: ${error.message}`);
|
|
369
379
|
this._clearPrefetch();
|
|
@@ -429,7 +439,7 @@ class Player extends EventEmitter {
|
|
|
429
439
|
}
|
|
430
440
|
|
|
431
441
|
this.audioPlayer.stop(true);
|
|
432
|
-
log.info('PLAYER', `Paused at ${Math.floor(this._positionMs / 1000)}s (
|
|
442
|
+
log.info('PLAYER', `Paused playback at ${Math.floor(this._positionMs / 1000)}s (Reason: User Request)`);
|
|
433
443
|
|
|
434
444
|
return true;
|
|
435
445
|
}
|
|
@@ -438,7 +448,7 @@ class Player extends EventEmitter {
|
|
|
438
448
|
if (!this._playing || !this._paused) return false;
|
|
439
449
|
|
|
440
450
|
if (!this.stream && this.queue.current) {
|
|
441
|
-
log.info('PLAYER', `Resuming from ${Math.floor(this._positionMs / 1000)}s
|
|
451
|
+
log.info('PLAYER', `Resuming playback from ${Math.floor(this._positionMs / 1000)}s`);
|
|
442
452
|
|
|
443
453
|
this._changingStream = true;
|
|
444
454
|
const track = this.queue.current;
|
|
@@ -488,12 +498,17 @@ class Player extends EventEmitter {
|
|
|
488
498
|
const next = this.queue.shift();
|
|
489
499
|
this.audioPlayer.stop();
|
|
490
500
|
|
|
491
|
-
|
|
492
|
-
|
|
501
|
+
log.info('PLAYER', `Skipping track (Next: ${next ? next.title : 'End of queue'})`);
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
if (next) {
|
|
505
|
+
await this._playTrack(next);
|
|
506
|
+
}
|
|
507
|
+
} finally {
|
|
508
|
+
this._manualSkip = false;
|
|
493
509
|
}
|
|
494
510
|
|
|
495
|
-
this.
|
|
496
|
-
return next;
|
|
511
|
+
return this.queue.current || next;
|
|
497
512
|
}
|
|
498
513
|
|
|
499
514
|
async previous() {
|
|
@@ -508,9 +523,16 @@ class Player extends EventEmitter {
|
|
|
508
523
|
|
|
509
524
|
this._manualSkip = true;
|
|
510
525
|
this.audioPlayer.stop();
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
526
|
+
|
|
527
|
+
log.info('PLAYER', `Rewinding to previous track: ${prev.title}`);
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
await this._playTrack(prev);
|
|
531
|
+
} finally {
|
|
532
|
+
this._manualSkip = false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return this.queue.current || prev;
|
|
514
536
|
}
|
|
515
537
|
|
|
516
538
|
stop() {
|
|
@@ -527,11 +549,16 @@ class Player extends EventEmitter {
|
|
|
527
549
|
this._positionMs = 0;
|
|
528
550
|
this.queue.clear();
|
|
529
551
|
this.queue.setCurrent(null);
|
|
552
|
+
|
|
553
|
+
log.info('PLAYER', 'Stopped playback and cleared queue');
|
|
554
|
+
|
|
530
555
|
return true;
|
|
531
556
|
}
|
|
532
557
|
|
|
533
558
|
setVolume(volume) {
|
|
559
|
+
const oldVolume = this._volume;
|
|
534
560
|
this._volume = Math.max(0, Math.min(200, volume));
|
|
561
|
+
log.info('PLAYER', `Volume adjusted: ${oldVolume}% -> ${this._volume}%`);
|
|
535
562
|
return this._volume;
|
|
536
563
|
}
|
|
537
564
|
|
|
@@ -550,6 +577,8 @@ class Player extends EventEmitter {
|
|
|
550
577
|
this.stream.destroy();
|
|
551
578
|
}
|
|
552
579
|
|
|
580
|
+
log.info('PLAYER', `Seeking to ${Math.floor(positionMs / 1000)}s`);
|
|
581
|
+
|
|
553
582
|
try {
|
|
554
583
|
this.stream = createStream(track, filtersWithVolume, this.config);
|
|
555
584
|
const resource = await this.stream.create(positionMs);
|
|
@@ -565,6 +594,7 @@ class Player extends EventEmitter {
|
|
|
565
594
|
}
|
|
566
595
|
|
|
567
596
|
setLoop(mode) {
|
|
597
|
+
log.info('PLAYER', `Loop mode changed to: ${mode.toUpperCase()}`);
|
|
568
598
|
return this.queue.setRepeatMode(mode);
|
|
569
599
|
}
|
|
570
600
|
|
|
@@ -602,6 +632,7 @@ class Player extends EventEmitter {
|
|
|
602
632
|
|
|
603
633
|
async clearFilters() {
|
|
604
634
|
this._filters = {};
|
|
635
|
+
log.info('PLAYER', 'All audio filters cleared');
|
|
605
636
|
if (this._playing && this.queue.current) {
|
|
606
637
|
return this.setFilter('_trigger', null);
|
|
607
638
|
}
|
|
@@ -612,6 +643,7 @@ class Player extends EventEmitter {
|
|
|
612
643
|
if (!Array.isArray(bands) || bands.length !== 15) {
|
|
613
644
|
throw new Error('EQ must be an array of 15 band gains (-0.25 to 1.0)');
|
|
614
645
|
}
|
|
646
|
+
log.info('PLAYER', 'Applying custom 15-band Equalizer');
|
|
615
647
|
return this.setFilter('equalizer', bands);
|
|
616
648
|
}
|
|
617
649
|
|
|
@@ -621,12 +653,14 @@ class Player extends EventEmitter {
|
|
|
621
653
|
throw new Error(`Unknown preset: ${presetName}. Available: ${Object.keys(PRESETS).join(', ')}`);
|
|
622
654
|
}
|
|
623
655
|
delete this._filters.equalizer;
|
|
656
|
+
log.info('PLAYER', `Applying EQ preset: ${presetName.toUpperCase()}`);
|
|
624
657
|
return this.setFilter('preset', presetName);
|
|
625
658
|
}
|
|
626
659
|
|
|
627
660
|
async clearEQ() {
|
|
628
661
|
delete this._filters.equalizer;
|
|
629
662
|
delete this._filters.preset;
|
|
663
|
+
log.info('PLAYER', 'Equalizer cleared');
|
|
630
664
|
if (this._playing && this.queue.current) {
|
|
631
665
|
return this.setFilter('_trigger', null);
|
|
632
666
|
}
|
|
@@ -644,7 +678,6 @@ class Player extends EventEmitter {
|
|
|
644
678
|
}
|
|
645
679
|
|
|
646
680
|
const replace = options.replace ?? false;
|
|
647
|
-
const appliedFilters = {};
|
|
648
681
|
const newPresets = [];
|
|
649
682
|
|
|
650
683
|
for (const preset of presets) {
|
|
@@ -655,17 +688,11 @@ class Player extends EventEmitter {
|
|
|
655
688
|
log.warn('PLAYER', `Unknown effect preset: ${name}`);
|
|
656
689
|
continue;
|
|
657
690
|
}
|
|
658
|
-
|
|
659
|
-
const filters = applyEffectPreset(name, intensity);
|
|
660
|
-
if (filters) {
|
|
661
|
-
Object.assign(appliedFilters, filters);
|
|
662
|
-
newPresets.push({ name, intensity });
|
|
663
|
-
}
|
|
691
|
+
newPresets.push({ name, intensity });
|
|
664
692
|
}
|
|
665
693
|
|
|
666
694
|
if (replace) {
|
|
667
695
|
this._effectPresets = newPresets;
|
|
668
|
-
this._filters = appliedFilters;
|
|
669
696
|
} else {
|
|
670
697
|
const existingNames = this._effectPresets.map(p => p.name);
|
|
671
698
|
for (const preset of newPresets) {
|
|
@@ -676,9 +703,19 @@ class Player extends EventEmitter {
|
|
|
676
703
|
this._effectPresets.push(preset);
|
|
677
704
|
}
|
|
678
705
|
}
|
|
679
|
-
this._filters = { ...this._filters, ...appliedFilters };
|
|
680
706
|
}
|
|
681
707
|
|
|
708
|
+
// Re-calculate all filters from active presets
|
|
709
|
+
this._filters = {};
|
|
710
|
+
for (const preset of this._effectPresets) {
|
|
711
|
+
const filters = applyEffectPreset(preset.name, preset.intensity);
|
|
712
|
+
if (filters) {
|
|
713
|
+
Object.assign(this._filters, filters);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
log.info('PLAYER', `Active effects: ${this._effectPresets.map(p => p.name).join(' + ') || 'NONE'}`);
|
|
718
|
+
|
|
682
719
|
if (this._playing && this.queue.current) {
|
|
683
720
|
return this.setFilter('_trigger', null);
|
|
684
721
|
}
|
|
@@ -692,6 +729,7 @@ class Player extends EventEmitter {
|
|
|
692
729
|
async clearEffectPresets() {
|
|
693
730
|
this._effectPresets = [];
|
|
694
731
|
this._filters = {};
|
|
732
|
+
log.info('PLAYER', 'All effect presets cleared');
|
|
695
733
|
if (this._playing && this.queue.current) {
|
|
696
734
|
return this.setFilter('_trigger', null);
|
|
697
735
|
}
|
|
@@ -764,4 +802,4 @@ class Player extends EventEmitter {
|
|
|
764
802
|
}
|
|
765
803
|
}
|
|
766
804
|
|
|
767
|
-
module.exports = Player;
|
|
805
|
+
module.exports = Player;
|
package/src/discord/Stream.js
CHANGED
|
@@ -22,6 +22,17 @@ class StreamController {
|
|
|
22
22
|
this.destroyed = false;
|
|
23
23
|
this.startTime = null;
|
|
24
24
|
this.bytesReceived = 0;
|
|
25
|
+
this.bytesSent = 0;
|
|
26
|
+
this.ytdlpError = '';
|
|
27
|
+
this.ffmpegError = '';
|
|
28
|
+
|
|
29
|
+
// Timing metrics
|
|
30
|
+
this.metrics = {
|
|
31
|
+
metadata: 0,
|
|
32
|
+
spawn: 0,
|
|
33
|
+
firstByte: 0,
|
|
34
|
+
total: 0
|
|
35
|
+
};
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
async create(seekPosition = 0) {
|
|
@@ -34,6 +45,7 @@ class StreamController {
|
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
this.startTime = Date.now();
|
|
48
|
+
const startTimestamp = this.startTime;
|
|
37
49
|
const source = this.track.source || 'youtube';
|
|
38
50
|
|
|
39
51
|
let videoId = this.track._resolvedId || this.track.id;
|
|
@@ -44,10 +56,13 @@ class StreamController {
|
|
|
44
56
|
const spotify = require('../providers/spotify');
|
|
45
57
|
videoId = await spotify.resolveToYouTube(this.track.id, this.config);
|
|
46
58
|
this.track._resolvedId = videoId;
|
|
59
|
+
this.metrics.metadata = Date.now() - startTimestamp;
|
|
47
60
|
} catch (error) {
|
|
48
61
|
log.error('STREAM', `Spotify resolution failed: ${error.message}`);
|
|
49
62
|
throw new Error(`Failed to resolve Spotify track: ${error.message}`);
|
|
50
63
|
}
|
|
64
|
+
} else {
|
|
65
|
+
this.metrics.metadata = Date.now() - startTimestamp;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
if (!videoId || videoId === 'undefined') {
|
|
@@ -55,6 +70,17 @@ class StreamController {
|
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
log.info('STREAM', `Creating stream for ${videoId} (${source})`);
|
|
73
|
+
|
|
74
|
+
// Log Filter Chain
|
|
75
|
+
const filterNames = Object.keys(this.filters).filter(k => k !== 'start' && k !== '_trigger');
|
|
76
|
+
if (filterNames.length > 0) {
|
|
77
|
+
const chain = filterNames.map(name => {
|
|
78
|
+
const val = this.filters[name];
|
|
79
|
+
const displayVal = typeof val === 'object' ? JSON.stringify(val) : val;
|
|
80
|
+
return `[${name} (${displayVal})]`;
|
|
81
|
+
}).join(' -> ');
|
|
82
|
+
log.debug('STREAM', `Filter Chain: ${chain}`);
|
|
83
|
+
}
|
|
58
84
|
|
|
59
85
|
let url;
|
|
60
86
|
if (source === 'soundcloud') {
|
|
@@ -106,35 +132,45 @@ class StreamController {
|
|
|
106
132
|
ytdlpArgs.push('--sponsorblock-remove', categories.join(','));
|
|
107
133
|
}
|
|
108
134
|
|
|
109
|
-
|
|
110
|
-
env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
|
|
111
|
-
});
|
|
135
|
+
const env = { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH };
|
|
112
136
|
|
|
113
|
-
|
|
137
|
+
const spawnStart = Date.now();
|
|
138
|
+
this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, { env });
|
|
114
139
|
|
|
115
140
|
const ffmpegFilters = { ...this.filters };
|
|
116
|
-
if (seekPosition > 0) {
|
|
117
|
-
ffmpegFilters.start = seekPosition / 1000;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
141
|
const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
|
|
121
|
-
this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs);
|
|
142
|
+
this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs, { env });
|
|
143
|
+
|
|
144
|
+
this.metrics.spawn = Date.now() - spawnStart;
|
|
145
|
+
|
|
146
|
+
const { pipeline } = require('stream');
|
|
122
147
|
|
|
123
148
|
this._firstDataTime = null;
|
|
149
|
+
|
|
124
150
|
this.ytdlp.stdout.on('data', (chunk) => {
|
|
151
|
+
this.bytesReceived += chunk.length;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.ffmpeg.stdout.on('data', (chunk) => {
|
|
125
155
|
if (!this._firstDataTime) {
|
|
126
156
|
this._firstDataTime = Date.now() - this.startTime;
|
|
157
|
+
this.metrics.firstByte = Date.now() - (startTimestamp + this.metrics.metadata + this.metrics.spawn);
|
|
127
158
|
}
|
|
128
|
-
this.
|
|
159
|
+
this.bytesSent += chunk.length;
|
|
129
160
|
});
|
|
130
161
|
|
|
131
|
-
this.ytdlp.stdout
|
|
162
|
+
pipeline(this.ytdlp.stdout, this.ffmpeg.stdin, (err) => {
|
|
163
|
+
if (err && !this.destroyed) {
|
|
164
|
+
if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE' && err.code !== 'EPIPE') {
|
|
165
|
+
log.debug('STREAM', `Pipeline error: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
132
169
|
|
|
133
|
-
let ytdlpError = '';
|
|
134
170
|
this.ytdlp.stderr.on('data', (data) => {
|
|
135
171
|
if (this.destroyed) return;
|
|
136
172
|
const msg = data.toString();
|
|
137
|
-
ytdlpError += msg;
|
|
173
|
+
this.ytdlpError += msg;
|
|
138
174
|
if (msg.includes('ERROR:') && !msg.includes('Retrying') && !msg.includes('Broken pipe')) {
|
|
139
175
|
log.error('YTDLP', msg.trim());
|
|
140
176
|
} else if (!msg.includes('[download]') && !msg.includes('ETA') && !msg.includes('[youtube]') && !msg.includes('Retrying fragment') && !msg.includes('Got error')) {
|
|
@@ -145,6 +181,7 @@ class StreamController {
|
|
|
145
181
|
this.ffmpeg.stderr.on('data', (data) => {
|
|
146
182
|
if (this.destroyed) return;
|
|
147
183
|
const msg = data.toString();
|
|
184
|
+
this.ffmpegError += msg;
|
|
148
185
|
if ((msg.includes('Error') || msg.includes('error')) && !msg.includes('Connection reset') && !msg.includes('Broken pipe')) {
|
|
149
186
|
log.error('FFMPEG', msg.trim());
|
|
150
187
|
}
|
|
@@ -153,11 +190,11 @@ class StreamController {
|
|
|
153
190
|
this.ytdlp.on('close', (code) => {
|
|
154
191
|
if (code !== 0 && code !== null && !this.destroyed) {
|
|
155
192
|
log.error('STREAM', `yt-dlp failed (code: ${code}) for ${videoId}`);
|
|
156
|
-
if (ytdlpError) {
|
|
157
|
-
log.error('STREAM', `yt-dlp stderr: ${ytdlpError.slice(-500)}`);
|
|
193
|
+
if (this.ytdlpError) {
|
|
194
|
+
log.error('STREAM', `yt-dlp stderr: ${this.ytdlpError.slice(-500)}`);
|
|
158
195
|
}
|
|
159
196
|
}
|
|
160
|
-
if (this.ffmpeg && !this.ffmpeg.killed) {
|
|
197
|
+
if (this.ffmpeg && !this.ffmpeg.killed && this.ffmpeg.stdin) {
|
|
161
198
|
this.ffmpeg.stdin.end();
|
|
162
199
|
}
|
|
163
200
|
});
|
|
@@ -199,9 +236,10 @@ class StreamController {
|
|
|
199
236
|
inlineVolume: false
|
|
200
237
|
});
|
|
201
238
|
|
|
202
|
-
const elapsed = Date.now() -
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
const elapsed = Date.now() - startTimestamp;
|
|
240
|
+
this.metrics.total = elapsed;
|
|
241
|
+
|
|
242
|
+
log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte}ms] | Buffered: ${this.bytesSent}b`);
|
|
205
243
|
|
|
206
244
|
return this.resource;
|
|
207
245
|
}
|
|
@@ -210,7 +248,7 @@ class StreamController {
|
|
|
210
248
|
return new Promise((resolve, reject) => {
|
|
211
249
|
const timeoutMs = isLive ? 30000 : 15000;
|
|
212
250
|
const timeout = setTimeout(() => {
|
|
213
|
-
log.warn('STREAM', `Timeout waiting for data, proceeding anyway (received: ${this.
|
|
251
|
+
log.warn('STREAM', `Timeout waiting for data, proceeding anyway (received: ${this.bytesSent}, isLive: ${isLive})`);
|
|
214
252
|
resolve();
|
|
215
253
|
}, timeoutMs);
|
|
216
254
|
|
|
@@ -221,7 +259,7 @@ class StreamController {
|
|
|
221
259
|
clearInterval(checkInterval);
|
|
222
260
|
return;
|
|
223
261
|
}
|
|
224
|
-
if (this.
|
|
262
|
+
if (this.bytesSent > 0) {
|
|
225
263
|
resolved = true;
|
|
226
264
|
clearTimeout(timeout);
|
|
227
265
|
clearInterval(checkInterval);
|
|
@@ -234,7 +272,7 @@ class StreamController {
|
|
|
234
272
|
resolved = true;
|
|
235
273
|
clearTimeout(timeout);
|
|
236
274
|
clearInterval(checkInterval);
|
|
237
|
-
reject(new Error(
|
|
275
|
+
reject(new Error(`ffmpeg closed before producing data. ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'} | yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}`));
|
|
238
276
|
}
|
|
239
277
|
});
|
|
240
278
|
|
|
@@ -243,7 +281,7 @@ class StreamController {
|
|
|
243
281
|
resolved = true;
|
|
244
282
|
clearTimeout(timeout);
|
|
245
283
|
clearInterval(checkInterval);
|
|
246
|
-
reject(new Error(`yt-dlp failed with code ${code}`));
|
|
284
|
+
reject(new Error(`yt-dlp failed with code ${code}. stderr: ${this.ytdlpError.slice(-200) || 'none'}`));
|
|
247
285
|
}
|
|
248
286
|
});
|
|
249
287
|
});
|
|
@@ -254,7 +292,7 @@ class StreamController {
|
|
|
254
292
|
this.destroyed = true;
|
|
255
293
|
|
|
256
294
|
const elapsed = this.startTime ? Date.now() - this.startTime : 0;
|
|
257
|
-
log.info('STREAM', `Destroying stream | Duration: ${elapsed}ms |
|
|
295
|
+
log.info('STREAM', `Destroying stream | Duration: ${elapsed}ms | Data Out: ${(this.bytesSent / 1024 / 1024).toFixed(2)} MB`);
|
|
258
296
|
|
|
259
297
|
try {
|
|
260
298
|
if (this.ytdlp && this.ffmpeg) {
|
|
@@ -287,4 +325,4 @@ function createStream(track, filters, config) {
|
|
|
287
325
|
return new StreamController(track, filters, config);
|
|
288
326
|
}
|
|
289
327
|
|
|
290
|
-
module.exports = { createStream, StreamController };
|
|
328
|
+
module.exports = { createStream, StreamController };
|
package/src/filters/ffmpeg.js
CHANGED
|
@@ -15,27 +15,38 @@ const PRESETS = {
|
|
|
15
15
|
piano: [0.2, 0.15, 0.1, 0.05, 0, 0.05, 0.1, 0.15, 0.2, 0.2, 0.15, 0.1, 0.1, 0.15, 0.2],
|
|
16
16
|
vocal: [-0.2, -0.15, -0.1, 0, 0.2, 0.35, 0.4, 0.4, 0.35, 0.2, 0, -0.1, -0.15, -0.15, -0.1],
|
|
17
17
|
bass_heavy: [0.5, 0.45, 0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
18
|
-
treble_heavy: [0, 0, 0, 0, 0, 0, 0, 0, 0.1, 0.2, 0.3, 0.4, 0.45, 0.5, 0.5]
|
|
18
|
+
treble_heavy: [0, 0, 0, 0, 0, 0, 0, 0, 0.1, 0.2, 0.3, 0.4, 0.45, 0.5, 0.5],
|
|
19
|
+
extra_bass: [0.6, 0.55, 0.5, 0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
20
|
+
crystal_clear: [-0.1, -0.1, -0.05, 0, 0.1, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.5, 0.5, 0.5]
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
const EFFECT_PRESETS = {
|
|
22
|
-
bassboost: { filters: { bass:
|
|
24
|
+
bassboost: { filters: { bass: 12, equalizer: [0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, description: 'Heavy bass boost' },
|
|
23
25
|
nightcore: { filters: { speed: 1.25, pitch: 1.25 }, description: 'Speed up with higher pitch' },
|
|
24
26
|
vaporwave: { filters: { speed: 0.8, pitch: 0.8 }, description: 'Slow down with lower pitch' },
|
|
25
|
-
'8d': { filters: { rotation: { speed: 0.
|
|
27
|
+
'8d': { filters: { rotation: { speed: 0.15 } }, description: '8D rotating audio effect' },
|
|
26
28
|
karaoke: { filters: { karaoke: true }, description: 'Reduce vocals' },
|
|
27
29
|
trebleboost: { filters: { treble: 10 }, description: 'Boost treble frequencies' },
|
|
28
|
-
deep: { filters: { bass: 15, pitch: 0.
|
|
29
|
-
lofi: { filters: { lowpass:
|
|
30
|
-
radio: { filters: { highpass:
|
|
31
|
-
telephone: { filters: { highpass:
|
|
32
|
-
soft: { filters: { bass: -
|
|
33
|
-
loud: { filters: { bass:
|
|
34
|
-
chipmunk: { filters: { pitch: 1.
|
|
35
|
-
darth: { filters: { pitch: 0.
|
|
30
|
+
deep: { filters: { bass: 15, pitch: 0.85, equalizer: [0.5, 0.4, 0.3, 0.2, 0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, description: 'Deep bass with lower pitch' },
|
|
31
|
+
lofi: { filters: { lowpass: 2500, bass: 8, treble: -5 }, description: 'Lo-fi aesthetic' },
|
|
32
|
+
radio: { filters: { highpass: 400, lowpass: 4500, compressor: true }, description: 'Radio/telephone effect' },
|
|
33
|
+
telephone: { filters: { highpass: 600, lowpass: 3000, compressor: true }, description: 'Old telephone effect' },
|
|
34
|
+
soft: { filters: { bass: -3, treble: -5, volume: 80, compressor: true }, description: 'Softer, compressed sound' },
|
|
35
|
+
loud: { filters: { bass: 3, treble: 2, volume: 120, compressor: true, normalizer: true }, description: 'Louder, punchier sound' },
|
|
36
|
+
chipmunk: { filters: { pitch: 1.6 }, description: 'High-pitched chipmunk voice' },
|
|
37
|
+
darth: { filters: { pitch: 0.65 }, description: 'Deep Darth Vader voice' },
|
|
36
38
|
echo: { filters: { echo: true }, description: 'Echo/reverb effect' },
|
|
37
|
-
vibrato: { filters: { vibrato: { frequency:
|
|
38
|
-
tremolo: { filters: { tremolo: { frequency: 5, depth: 0.5 } }, description: 'Tremolo effect' }
|
|
39
|
+
vibrato: { filters: { vibrato: { frequency: 6, depth: 0.6 } }, description: 'Vibrato effect' },
|
|
40
|
+
tremolo: { filters: { tremolo: { frequency: 5, depth: 0.5 } }, description: 'Tremolo effect' },
|
|
41
|
+
reverb: { filters: { reverb: true }, description: 'Classic reverb effect' },
|
|
42
|
+
surround: { filters: { surround: true }, description: 'Surround sound effect' },
|
|
43
|
+
boost: { filters: { volume: 150, treble: 5, bass: 5, compressor: true }, description: 'Boost volume and clarity' },
|
|
44
|
+
subboost: { filters: { bass: 15, lowpass: 100 }, description: 'Extreme sub-woofer boost' },
|
|
45
|
+
pop: { filters: { preset: 'pop' }, description: 'Pop EQ preset' },
|
|
46
|
+
rock: { filters: { preset: 'rock' }, description: 'Rock EQ preset' },
|
|
47
|
+
electronic: { filters: { preset: 'electronic' }, description: 'Electronic EQ preset' },
|
|
48
|
+
jazz: { filters: { preset: 'jazz' }, description: 'Jazz EQ preset' },
|
|
49
|
+
classical: { filters: { preset: 'classical' }, description: 'Classical EQ preset' }
|
|
39
50
|
};
|
|
40
51
|
|
|
41
52
|
function buildEqualizer(bands) {
|
|
@@ -84,11 +95,7 @@ function buildFfmpegArgs(filters = {}, config = {}) {
|
|
|
84
95
|
const speed = parseFloat(filters.speed);
|
|
85
96
|
if (!isNaN(speed) && speed !== 1) {
|
|
86
97
|
const clampedSpeed = Math.max(0.5, Math.min(2.0, speed));
|
|
87
|
-
|
|
88
|
-
audioFilters.push(`atempo=${clampedSpeed}`);
|
|
89
|
-
} else if (clampedSpeed <= 2) {
|
|
90
|
-
audioFilters.push(`atempo=${clampedSpeed}`);
|
|
91
|
-
}
|
|
98
|
+
audioFilters.push(`atempo=${clampedSpeed}`);
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
const pitch = parseFloat(filters.pitch);
|
|
@@ -205,18 +212,24 @@ function buildFfmpegArgs(filters = {}, config = {}) {
|
|
|
205
212
|
audioFilters.push('loudnorm');
|
|
206
213
|
}
|
|
207
214
|
|
|
215
|
+
if (filters.echo === 'true' || filters.echo === true) {
|
|
216
|
+
audioFilters.push('aecho=0.8:0.88:60:0.4');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (filters.reverb === 'true' || filters.reverb === true) {
|
|
220
|
+
audioFilters.push('aecho=0.8:0.88:60:0.4,aecho=0.8:0.88:30:0.2');
|
|
221
|
+
}
|
|
222
|
+
|
|
208
223
|
if (filters.nightcore === 'true' || filters.nightcore === true) {
|
|
209
|
-
audioFilters.push('atempo=1.25');
|
|
210
|
-
audioFilters.push('asetrate=48000*1.25,aresample=48000');
|
|
224
|
+
audioFilters.push('atempo=1.25,asetrate=48000*1.25,aresample=48000');
|
|
211
225
|
}
|
|
212
226
|
|
|
213
227
|
if (filters.vaporwave === 'true' || filters.vaporwave === true) {
|
|
214
|
-
audioFilters.push('atempo=0.8');
|
|
215
|
-
audioFilters.push('asetrate=48000*0.8,aresample=48000');
|
|
228
|
+
audioFilters.push('atempo=0.8,asetrate=48000*0.8,aresample=48000');
|
|
216
229
|
}
|
|
217
230
|
|
|
218
231
|
if (filters.bassboost === 'true' || filters.bassboost === true) {
|
|
219
|
-
audioFilters.push('bass=g=10');
|
|
232
|
+
audioFilters.push('bass=g=10,equalizer=f=40:width_type=h:width=20:g=10');
|
|
220
233
|
}
|
|
221
234
|
|
|
222
235
|
if (filters['8d'] === 'true' || filters['8d'] === true) {
|
|
@@ -275,6 +288,8 @@ function getAvailableFilters() {
|
|
|
275
288
|
nightcore: { type: 'boolean', description: 'Nightcore preset' },
|
|
276
289
|
vaporwave: { type: 'boolean', description: 'Vaporwave preset' },
|
|
277
290
|
bassboost: { type: 'boolean', description: 'Bass boost preset' },
|
|
291
|
+
echo: { type: 'boolean', description: 'Echo effect' },
|
|
292
|
+
reverb: { type: 'boolean', description: 'Reverb effect' },
|
|
278
293
|
'8d': { type: 'boolean', description: '8D audio effect' }
|
|
279
294
|
};
|
|
280
295
|
}
|
|
@@ -306,14 +321,24 @@ function applyEffectPreset(name, intensity = 1.0) {
|
|
|
306
321
|
const filters = {};
|
|
307
322
|
for (const [key, value] of Object.entries(preset.filters)) {
|
|
308
323
|
if (typeof value === 'number') {
|
|
309
|
-
|
|
324
|
+
if (key === 'speed' || key === 'pitch') {
|
|
325
|
+
// Scale relative to 1.0
|
|
326
|
+
filters[key] = 1.0 + (value - 1.0) * intensity;
|
|
327
|
+
} else {
|
|
328
|
+
filters[key] = value * intensity;
|
|
329
|
+
}
|
|
310
330
|
} else if (typeof value === 'boolean') {
|
|
311
331
|
filters[key] = value;
|
|
312
332
|
} else if (typeof value === 'object') {
|
|
313
333
|
filters[key] = { ...value };
|
|
314
334
|
for (const [k, v] of Object.entries(filters[key])) {
|
|
315
335
|
if (typeof v === 'number') {
|
|
316
|
-
|
|
336
|
+
if (k === 'speed' || k === 'pitch' || k === 'frequency') {
|
|
337
|
+
// Some frequency based ones might also need base values but speed/pitch are most critical
|
|
338
|
+
filters[key][k] = 1.0 + (v - 1.0) * intensity;
|
|
339
|
+
} else {
|
|
340
|
+
filters[key][k] = v * intensity;
|
|
341
|
+
}
|
|
317
342
|
}
|
|
318
343
|
}
|
|
319
344
|
}
|
|
@@ -332,4 +357,4 @@ module.exports = {
|
|
|
332
357
|
PRESETS,
|
|
333
358
|
EQ_BANDS,
|
|
334
359
|
EFFECT_PRESETS
|
|
335
|
-
};
|
|
360
|
+
};
|
package/src/providers/youtube.js
CHANGED
|
@@ -3,23 +3,42 @@ const { buildFfmpegArgs } = require('../filters/ffmpeg');
|
|
|
3
3
|
const { registerStream, unregisterStream } = require('../utils/stream');
|
|
4
4
|
const log = require('../utils/logger');
|
|
5
5
|
|
|
6
|
-
async function search(query, limit, config) {
|
|
6
|
+
async function search(query, limit, config, options = {}) {
|
|
7
7
|
const startTime = Date.now();
|
|
8
|
-
log.info('YOUTUBE', `Searching: "${query}" (limit: ${limit})`);
|
|
8
|
+
log.info('YOUTUBE', `Searching: "${query}" (limit: ${limit}, type: ${options.type || 'all'}, sort: ${options.sort || 'relevance'})`);
|
|
9
9
|
|
|
10
10
|
return new Promise((resolve, reject) => {
|
|
11
11
|
const args = [
|
|
12
12
|
'-q', '--no-warnings',
|
|
13
13
|
'--flat-playlist',
|
|
14
14
|
'--skip-download',
|
|
15
|
-
'-J'
|
|
16
|
-
`ytsearch${limit}:${query}`
|
|
15
|
+
'-J'
|
|
17
16
|
];
|
|
18
17
|
|
|
19
18
|
if (config.cookiesPath) {
|
|
20
19
|
args.unshift('--cookies', config.cookiesPath);
|
|
21
20
|
}
|
|
22
21
|
|
|
22
|
+
let searchQuery = query;
|
|
23
|
+
|
|
24
|
+
// Handle sorting by modifying search query or using filters
|
|
25
|
+
if (options.sort === 'views' || options.sort === 'popular') {
|
|
26
|
+
searchQuery += ' most viewed';
|
|
27
|
+
} else if (options.sort === 'date' || options.sort === 'latest') {
|
|
28
|
+
searchQuery += ' new';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Handle type filtering
|
|
32
|
+
if (options.type === 'live') {
|
|
33
|
+
args.push('--match-filter', 'live_status = is_live');
|
|
34
|
+
} else if (options.type === 'playlist') {
|
|
35
|
+
args.push('--match-filter', 'playlist_count > 0');
|
|
36
|
+
} else if (options.type === 'video') {
|
|
37
|
+
args.push('--match-filter', 'live_status != is_live');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
args.push(`ytsearch${limit}:${searchQuery}`);
|
|
41
|
+
|
|
23
42
|
const proc = spawn(config.ytdlpPath, args, {
|
|
24
43
|
env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
|
|
25
44
|
});
|
package/src/server.js
CHANGED
|
@@ -93,14 +93,14 @@ class Server {
|
|
|
93
93
|
if (!this._isProviderEnabled('youtube')) {
|
|
94
94
|
return res.status(400).json({ error: 'YouTube provider is disabled' });
|
|
95
95
|
}
|
|
96
|
-
const { q, limit = 10 } = req.query;
|
|
96
|
+
const { q, limit = 10, type, sort } = req.query;
|
|
97
97
|
if (!q) return res.status(400).json({ error: 'Missing query parameter: q' });
|
|
98
98
|
|
|
99
|
-
const cacheKey = `yt:search:${q}:${limit}`;
|
|
99
|
+
const cacheKey = `yt:search:${q}:${limit}:${type}:${sort}`;
|
|
100
100
|
const cached = cache.get(cacheKey);
|
|
101
101
|
if (cached) return res.json(cached);
|
|
102
102
|
|
|
103
|
-
const results = await youtube.search(q, parseInt(limit), this.config);
|
|
103
|
+
const results = await youtube.search(q, parseInt(limit), this.config, { type, sort });
|
|
104
104
|
cache.set(cacheKey, results, this.config.cache.searchTTL);
|
|
105
105
|
res.json(results);
|
|
106
106
|
} catch (error) {
|
|
@@ -228,7 +228,7 @@ class Server {
|
|
|
228
228
|
|
|
229
229
|
this.app.get('/search', async (req, res) => {
|
|
230
230
|
try {
|
|
231
|
-
const { q, source = 'youtube', limit = 10 } = req.query;
|
|
231
|
+
const { q, source = 'youtube', limit = 10, type, sort } = req.query;
|
|
232
232
|
if (!q) return res.status(400).json({ error: 'Missing query parameter: q' });
|
|
233
233
|
|
|
234
234
|
let results;
|
|
@@ -252,7 +252,7 @@ class Server {
|
|
|
252
252
|
if (!this._isProviderEnabled('youtube')) {
|
|
253
253
|
return res.status(400).json({ error: 'YouTube provider is disabled' });
|
|
254
254
|
}
|
|
255
|
-
results = await youtube.search(q, parseInt(limit), this.config);
|
|
255
|
+
results = await youtube.search(q, parseInt(limit), this.config, { type, sort });
|
|
256
256
|
}
|
|
257
257
|
res.json(results);
|
|
258
258
|
} catch (error) {
|
package/src/utils/logger.js
CHANGED
|
@@ -52,45 +52,67 @@ function formatTime() {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function formatTag(tag, color) {
|
|
55
|
-
return colorize(color, `[${tag}]`);
|
|
55
|
+
return colorize(color, `[${tag.toUpperCase()}]`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function debug(tag, ...args) {
|
|
59
59
|
if (currentLevel < LOG_LEVELS.debug) return;
|
|
60
|
-
console.log(formatTime(), formatTag(tag, 'gray'), colorize('dim', args.join(' ')));
|
|
60
|
+
console.log(formatTime(), 'đ', formatTag(tag, 'gray'), colorize('dim', args.join(' ')));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function info(tag, ...args) {
|
|
64
64
|
if (currentLevel < LOG_LEVELS.info) return;
|
|
65
|
-
|
|
65
|
+
const tagUpper = tag.toUpperCase();
|
|
66
|
+
const color = {
|
|
67
|
+
'MANAGER': 'cyan',
|
|
68
|
+
'PLAYER': 'blue',
|
|
69
|
+
'YOUTUBE': 'red',
|
|
70
|
+
'SPOTIFY': 'green',
|
|
71
|
+
'SOUNDCLOUD': 'yellow',
|
|
72
|
+
'STREAM': 'cyan'
|
|
73
|
+
}[tagUpper] || 'cyan';
|
|
74
|
+
|
|
75
|
+
const emojiMap = {
|
|
76
|
+
'MANAGER': 'âšī¸ ',
|
|
77
|
+
'YOUTUBE': 'đ´ ',
|
|
78
|
+
'SPOTIFY': 'đĸ ',
|
|
79
|
+
'SOUNDCLOUD': 'đ ',
|
|
80
|
+
'PLAYER': 'đš ',
|
|
81
|
+
'STREAM': 'đĩ '
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const emoji = emojiMap[tagUpper] || ' ';
|
|
85
|
+
console.log(formatTime(), emoji, formatTag(tag, color), ...args);
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
function success(tag, ...args) {
|
|
69
89
|
if (currentLevel < LOG_LEVELS.info) return;
|
|
70
|
-
console.log(formatTime(), formatTag(tag, 'green'), colorize('green', args.join(' ')));
|
|
90
|
+
console.log(formatTime(), 'â
', formatTag(tag, 'green'), colorize('green', args.join(' ')));
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
function warn(tag, ...args) {
|
|
74
94
|
if (currentLevel < LOG_LEVELS.warn) return;
|
|
75
|
-
console.warn(formatTime(), formatTag(tag, 'yellow'), colorize('yellow', args.join(' ')));
|
|
95
|
+
console.warn(formatTime(), 'â ī¸ ', formatTag(tag, 'yellow'), colorize('yellow', args.join(' ')));
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
function error(tag, ...args) {
|
|
79
99
|
if (currentLevel < LOG_LEVELS.error) return;
|
|
80
|
-
console.error(formatTime(), formatTag(tag, 'red'), colorize('red', args.join(' ')));
|
|
100
|
+
console.error(formatTime(), 'â ', formatTag(tag, 'red'), colorize('red', args.join(' ')));
|
|
81
101
|
}
|
|
82
102
|
|
|
83
103
|
function stream(source, id, message) {
|
|
84
104
|
if (currentLevel < LOG_LEVELS.debug) return;
|
|
85
|
-
const
|
|
86
|
-
youtube: 'red',
|
|
87
|
-
spotify: 'green',
|
|
88
|
-
soundcloud: 'yellow'
|
|
89
|
-
}
|
|
105
|
+
const sourceMap = {
|
|
106
|
+
youtube: { color: 'red', emoji: 'đ´ ' },
|
|
107
|
+
spotify: { color: 'green', emoji: 'đĸ ' },
|
|
108
|
+
soundcloud: { color: 'yellow', emoji: 'đ ' }
|
|
109
|
+
};
|
|
110
|
+
const { color, emoji } = sourceMap[source.toLowerCase()] || { color: 'white', emoji: 'đĩ ' };
|
|
90
111
|
|
|
91
112
|
console.log(
|
|
92
113
|
formatTime(),
|
|
93
|
-
|
|
114
|
+
emoji,
|
|
115
|
+
colorize('cyan', '[STREAM]'),
|
|
94
116
|
colorize('dim', `[${id}]`),
|
|
95
117
|
message
|
|
96
118
|
);
|