streamify-audio 2.1.15 â 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 +54 -31
- 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,18 +498,16 @@ class Player extends EventEmitter {
|
|
|
488
498
|
const next = this.queue.shift();
|
|
489
499
|
this.audioPlayer.stop();
|
|
490
500
|
|
|
491
|
-
|
|
492
|
-
await this._playTrack(next);
|
|
493
|
-
|
|
494
|
-
await new Promise(resolve => setTimeout(resolve, 250));
|
|
501
|
+
log.info('PLAYER', `Skipping track (Next: ${next ? next.title : 'End of queue'})`);
|
|
495
502
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
503
|
+
try {
|
|
504
|
+
if (next) {
|
|
505
|
+
await this._playTrack(next);
|
|
499
506
|
}
|
|
507
|
+
} finally {
|
|
508
|
+
this._manualSkip = false;
|
|
500
509
|
}
|
|
501
510
|
|
|
502
|
-
this._manualSkip = false;
|
|
503
511
|
return this.queue.current || next;
|
|
504
512
|
}
|
|
505
513
|
|
|
@@ -515,16 +523,15 @@ class Player extends EventEmitter {
|
|
|
515
523
|
|
|
516
524
|
this._manualSkip = true;
|
|
517
525
|
this.audioPlayer.stop();
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
await new Promise(resolve => setTimeout(resolve, 250));
|
|
526
|
+
|
|
527
|
+
log.info('PLAYER', `Rewinding to previous track: ${prev.title}`);
|
|
521
528
|
|
|
522
|
-
|
|
529
|
+
try {
|
|
530
|
+
await this._playTrack(prev);
|
|
531
|
+
} finally {
|
|
523
532
|
this._manualSkip = false;
|
|
524
|
-
return this.queue.current;
|
|
525
533
|
}
|
|
526
534
|
|
|
527
|
-
this._manualSkip = false;
|
|
528
535
|
return this.queue.current || prev;
|
|
529
536
|
}
|
|
530
537
|
|
|
@@ -542,11 +549,16 @@ class Player extends EventEmitter {
|
|
|
542
549
|
this._positionMs = 0;
|
|
543
550
|
this.queue.clear();
|
|
544
551
|
this.queue.setCurrent(null);
|
|
552
|
+
|
|
553
|
+
log.info('PLAYER', 'Stopped playback and cleared queue');
|
|
554
|
+
|
|
545
555
|
return true;
|
|
546
556
|
}
|
|
547
557
|
|
|
548
558
|
setVolume(volume) {
|
|
559
|
+
const oldVolume = this._volume;
|
|
549
560
|
this._volume = Math.max(0, Math.min(200, volume));
|
|
561
|
+
log.info('PLAYER', `Volume adjusted: ${oldVolume}% -> ${this._volume}%`);
|
|
550
562
|
return this._volume;
|
|
551
563
|
}
|
|
552
564
|
|
|
@@ -565,6 +577,8 @@ class Player extends EventEmitter {
|
|
|
565
577
|
this.stream.destroy();
|
|
566
578
|
}
|
|
567
579
|
|
|
580
|
+
log.info('PLAYER', `Seeking to ${Math.floor(positionMs / 1000)}s`);
|
|
581
|
+
|
|
568
582
|
try {
|
|
569
583
|
this.stream = createStream(track, filtersWithVolume, this.config);
|
|
570
584
|
const resource = await this.stream.create(positionMs);
|
|
@@ -580,6 +594,7 @@ class Player extends EventEmitter {
|
|
|
580
594
|
}
|
|
581
595
|
|
|
582
596
|
setLoop(mode) {
|
|
597
|
+
log.info('PLAYER', `Loop mode changed to: ${mode.toUpperCase()}`);
|
|
583
598
|
return this.queue.setRepeatMode(mode);
|
|
584
599
|
}
|
|
585
600
|
|
|
@@ -617,6 +632,7 @@ class Player extends EventEmitter {
|
|
|
617
632
|
|
|
618
633
|
async clearFilters() {
|
|
619
634
|
this._filters = {};
|
|
635
|
+
log.info('PLAYER', 'All audio filters cleared');
|
|
620
636
|
if (this._playing && this.queue.current) {
|
|
621
637
|
return this.setFilter('_trigger', null);
|
|
622
638
|
}
|
|
@@ -627,6 +643,7 @@ class Player extends EventEmitter {
|
|
|
627
643
|
if (!Array.isArray(bands) || bands.length !== 15) {
|
|
628
644
|
throw new Error('EQ must be an array of 15 band gains (-0.25 to 1.0)');
|
|
629
645
|
}
|
|
646
|
+
log.info('PLAYER', 'Applying custom 15-band Equalizer');
|
|
630
647
|
return this.setFilter('equalizer', bands);
|
|
631
648
|
}
|
|
632
649
|
|
|
@@ -636,12 +653,14 @@ class Player extends EventEmitter {
|
|
|
636
653
|
throw new Error(`Unknown preset: ${presetName}. Available: ${Object.keys(PRESETS).join(', ')}`);
|
|
637
654
|
}
|
|
638
655
|
delete this._filters.equalizer;
|
|
656
|
+
log.info('PLAYER', `Applying EQ preset: ${presetName.toUpperCase()}`);
|
|
639
657
|
return this.setFilter('preset', presetName);
|
|
640
658
|
}
|
|
641
659
|
|
|
642
660
|
async clearEQ() {
|
|
643
661
|
delete this._filters.equalizer;
|
|
644
662
|
delete this._filters.preset;
|
|
663
|
+
log.info('PLAYER', 'Equalizer cleared');
|
|
645
664
|
if (this._playing && this.queue.current) {
|
|
646
665
|
return this.setFilter('_trigger', null);
|
|
647
666
|
}
|
|
@@ -659,7 +678,6 @@ class Player extends EventEmitter {
|
|
|
659
678
|
}
|
|
660
679
|
|
|
661
680
|
const replace = options.replace ?? false;
|
|
662
|
-
const appliedFilters = {};
|
|
663
681
|
const newPresets = [];
|
|
664
682
|
|
|
665
683
|
for (const preset of presets) {
|
|
@@ -670,17 +688,11 @@ class Player extends EventEmitter {
|
|
|
670
688
|
log.warn('PLAYER', `Unknown effect preset: ${name}`);
|
|
671
689
|
continue;
|
|
672
690
|
}
|
|
673
|
-
|
|
674
|
-
const filters = applyEffectPreset(name, intensity);
|
|
675
|
-
if (filters) {
|
|
676
|
-
Object.assign(appliedFilters, filters);
|
|
677
|
-
newPresets.push({ name, intensity });
|
|
678
|
-
}
|
|
691
|
+
newPresets.push({ name, intensity });
|
|
679
692
|
}
|
|
680
693
|
|
|
681
694
|
if (replace) {
|
|
682
695
|
this._effectPresets = newPresets;
|
|
683
|
-
this._filters = appliedFilters;
|
|
684
696
|
} else {
|
|
685
697
|
const existingNames = this._effectPresets.map(p => p.name);
|
|
686
698
|
for (const preset of newPresets) {
|
|
@@ -691,9 +703,19 @@ class Player extends EventEmitter {
|
|
|
691
703
|
this._effectPresets.push(preset);
|
|
692
704
|
}
|
|
693
705
|
}
|
|
694
|
-
this._filters = { ...this._filters, ...appliedFilters };
|
|
695
706
|
}
|
|
696
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
|
+
|
|
697
719
|
if (this._playing && this.queue.current) {
|
|
698
720
|
return this.setFilter('_trigger', null);
|
|
699
721
|
}
|
|
@@ -707,6 +729,7 @@ class Player extends EventEmitter {
|
|
|
707
729
|
async clearEffectPresets() {
|
|
708
730
|
this._effectPresets = [];
|
|
709
731
|
this._filters = {};
|
|
732
|
+
log.info('PLAYER', 'All effect presets cleared');
|
|
710
733
|
if (this._playing && this.queue.current) {
|
|
711
734
|
return this.setFilter('_trigger', null);
|
|
712
735
|
}
|
|
@@ -779,4 +802,4 @@ class Player extends EventEmitter {
|
|
|
779
802
|
}
|
|
780
803
|
}
|
|
781
804
|
|
|
782
|
-
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
|
);
|