streamify-audio 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ const cache = new Map();
2
+
3
+ function get(key) {
4
+ const item = cache.get(key);
5
+ if (!item) return null;
6
+
7
+ if (Date.now() > item.expiry) {
8
+ cache.delete(key);
9
+ return null;
10
+ }
11
+
12
+ return item.value;
13
+ }
14
+
15
+ function set(key, value, ttlSeconds = 300) {
16
+ cache.set(key, {
17
+ value,
18
+ expiry: Date.now() + (ttlSeconds * 1000)
19
+ });
20
+ }
21
+
22
+ function del(key) {
23
+ cache.delete(key);
24
+ }
25
+
26
+ function clear() {
27
+ cache.clear();
28
+ }
29
+
30
+ function stats() {
31
+ let valid = 0;
32
+ let expired = 0;
33
+ const now = Date.now();
34
+
35
+ for (const [key, item] of cache) {
36
+ if (now > item.expiry) {
37
+ expired++;
38
+ } else {
39
+ valid++;
40
+ }
41
+ }
42
+
43
+ return {
44
+ entries: cache.size,
45
+ valid,
46
+ expired
47
+ };
48
+ }
49
+
50
+ function cleanup() {
51
+ const now = Date.now();
52
+ for (const [key, item] of cache) {
53
+ if (now > item.expiry) {
54
+ cache.delete(key);
55
+ }
56
+ }
57
+ }
58
+
59
+ setInterval(cleanup, 60000);
60
+
61
+ module.exports = { get, set, del, clear, stats };
package/src/config.js ADDED
@@ -0,0 +1,133 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+
5
+ const defaults = {
6
+ port: 8787,
7
+ host: '0.0.0.0',
8
+ ytdlpPath: null,
9
+ ffmpegPath: null,
10
+ cookies: null,
11
+ cookiesPath: null,
12
+ providers: {
13
+ youtube: { enabled: true },
14
+ spotify: { enabled: true },
15
+ soundcloud: { enabled: true }
16
+ },
17
+ spotify: {
18
+ clientId: null,
19
+ clientSecret: null
20
+ },
21
+ audio: {
22
+ bitrate: '128k',
23
+ format: 'opus'
24
+ },
25
+ cache: {
26
+ enabled: true,
27
+ searchTTL: 300,
28
+ infoTTL: 3600,
29
+ redis: null
30
+ }
31
+ };
32
+
33
+ function findExecutable(name) {
34
+ try {
35
+ const result = execSync(`which ${name}`, { encoding: 'utf-8' }).trim();
36
+ return result || null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function load(options = {}) {
43
+ let fileConfig = {};
44
+
45
+ if (options.configPath && fs.existsSync(options.configPath)) {
46
+ try {
47
+ fileConfig = JSON.parse(fs.readFileSync(options.configPath, 'utf-8'));
48
+ } catch (e) {
49
+ console.warn('[CONFIG] Failed to parse config file:', e.message);
50
+ }
51
+ } else if (fs.existsSync('./config.json')) {
52
+ try {
53
+ fileConfig = JSON.parse(fs.readFileSync('./config.json', 'utf-8'));
54
+ } catch (e) {}
55
+ }
56
+
57
+ const config = {
58
+ ...defaults,
59
+ ...fileConfig,
60
+ ...options,
61
+ providers: {
62
+ youtube: { enabled: true, ...fileConfig.providers?.youtube, ...options.providers?.youtube },
63
+ spotify: { enabled: true, ...fileConfig.providers?.spotify, ...options.providers?.spotify },
64
+ soundcloud: { enabled: true, ...fileConfig.providers?.soundcloud, ...options.providers?.soundcloud }
65
+ },
66
+ spotify: {
67
+ ...defaults.spotify,
68
+ ...fileConfig.spotify,
69
+ ...options.spotify
70
+ },
71
+ audio: {
72
+ ...defaults.audio,
73
+ ...fileConfig.audio,
74
+ ...options.audio
75
+ },
76
+ cache: {
77
+ ...defaults.cache,
78
+ ...fileConfig.cache,
79
+ ...options.cache
80
+ }
81
+ };
82
+
83
+ config.port = parseInt(process.env.PORT || config.port, 10);
84
+ config.host = process.env.HOST || config.host;
85
+
86
+ if (process.env.SPOTIFY_CLIENT_ID) {
87
+ config.spotify.clientId = process.env.SPOTIFY_CLIENT_ID;
88
+ }
89
+ if (process.env.SPOTIFY_CLIENT_SECRET) {
90
+ config.spotify.clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
91
+ }
92
+ if (process.env.COOKIES) {
93
+ config.cookies = process.env.COOKIES;
94
+ }
95
+ if (process.env.COOKIES_PATH) {
96
+ config.cookiesPath = process.env.COOKIES_PATH;
97
+ }
98
+ if (process.env.YTDLP_PATH) {
99
+ config.ytdlpPath = process.env.YTDLP_PATH;
100
+ }
101
+
102
+ if (config.cookies && !config.cookiesPath) {
103
+ const tempDir = path.join(require('os').tmpdir(), 'streamify');
104
+ if (!fs.existsSync(tempDir)) {
105
+ fs.mkdirSync(tempDir, { recursive: true });
106
+ }
107
+ config.cookiesPath = path.join(tempDir, 'cookies.txt');
108
+ fs.writeFileSync(config.cookiesPath, config.cookies);
109
+ console.log('[CONFIG] Cookies written to temp file');
110
+ }
111
+
112
+ if (!config.ytdlpPath) {
113
+ config.ytdlpPath = findExecutable('yt-dlp');
114
+ }
115
+ if (!config.ffmpegPath) {
116
+ config.ffmpegPath = findExecutable('ffmpeg');
117
+ }
118
+
119
+ if (!config.ytdlpPath) {
120
+ throw new Error('yt-dlp not found. Install it: pip install yt-dlp');
121
+ }
122
+ if (!config.ffmpegPath) {
123
+ throw new Error('ffmpeg not found. Install it: apt install ffmpeg');
124
+ }
125
+
126
+ return config;
127
+ }
128
+
129
+ function loadConfig(options = {}) {
130
+ return load(options);
131
+ }
132
+
133
+ module.exports = { load, loadConfig, defaults };
@@ -0,0 +1,453 @@
1
+ const { EventEmitter } = require('events');
2
+ const Player = require('./Player');
3
+ const youtube = require('../providers/youtube');
4
+ const spotify = require('../providers/spotify');
5
+ const soundcloud = require('../providers/soundcloud');
6
+ const log = require('../utils/logger');
7
+ const { loadConfig } = require('../config');
8
+
9
+ class Manager extends EventEmitter {
10
+ constructor(client, options = {}) {
11
+ super();
12
+ this.client = client;
13
+ this.players = new Map();
14
+
15
+ this.config = loadConfig({
16
+ ytdlpPath: options.ytdlpPath,
17
+ ffmpegPath: options.ffmpegPath,
18
+ cookiesPath: options.cookiesPath,
19
+ providers: options.providers,
20
+ spotify: options.spotify,
21
+ audio: options.audio,
22
+ defaultVolume: options.defaultVolume || 80,
23
+ sponsorblock: options.sponsorblock
24
+ });
25
+
26
+ this.defaultVolume = options.defaultVolume || 80;
27
+ this.maxPreviousTracks = options.maxPreviousTracks || 25;
28
+
29
+ this.autoLeave = {
30
+ enabled: options.autoLeave?.enabled ?? true,
31
+ emptyDelay: options.autoLeave?.emptyDelay ?? 30000,
32
+ inactivityTimeout: options.autoLeave?.inactivityTimeout ?? 300000
33
+ };
34
+
35
+ this.autoPause = {
36
+ enabled: options.autoPause?.enabled ?? true,
37
+ minUsers: options.autoPause?.minUsers ?? 1
38
+ };
39
+
40
+ this.autoplay = {
41
+ enabled: options.autoplay?.enabled ?? false,
42
+ maxTracks: options.autoplay?.maxTracks ?? 5
43
+ };
44
+
45
+ this._setupVoiceStateListener();
46
+
47
+ log.info('MANAGER', 'Streamify Manager initialized');
48
+ }
49
+
50
+ _setupVoiceStateListener() {
51
+ this.client.on('voiceStateUpdate', (oldState, newState) => {
52
+ const player = this.players.get(oldState.guild.id) || this.players.get(newState.guild.id);
53
+ if (!player) return;
54
+
55
+ const botId = this.client.user.id;
56
+
57
+ if (oldState.id === botId && !newState.channelId) {
58
+ player.destroy();
59
+ return;
60
+ }
61
+
62
+ if (oldState.id === botId && newState.channelId && oldState.channelId !== newState.channelId) {
63
+ player.voiceChannelId = newState.channelId;
64
+ player.emit('channelMove', newState.channelId);
65
+ return;
66
+ }
67
+
68
+ if (oldState.channelId === player.voiceChannelId || newState.channelId === player.voiceChannelId) {
69
+ const channel = this.client.channels.cache.get(player.voiceChannelId);
70
+ if (!channel) return;
71
+
72
+ const members = channel.members.filter(m => !m.user.bot);
73
+ const memberCount = members.size;
74
+
75
+ if (oldState.channelId === player.voiceChannelId && newState.channelId !== player.voiceChannelId) {
76
+ player.emit('userLeave', oldState.member, memberCount);
77
+ }
78
+
79
+ if (newState.channelId === player.voiceChannelId && oldState.channelId !== player.voiceChannelId) {
80
+ player.emit('userJoin', newState.member, memberCount);
81
+ }
82
+
83
+ if (memberCount < player.autoPause.minUsers) {
84
+ if (memberCount === 0) {
85
+ player.emit('channelEmpty');
86
+ }
87
+
88
+ if (player.autoPause.enabled && player.playing && !player._autoPaused) {
89
+ player._autoPaused = true;
90
+ player.pause();
91
+ player.emit('autoPause', memberCount);
92
+ log.info('PLAYER', `Auto-paused (${memberCount} users in channel)`);
93
+ }
94
+
95
+ if (this.autoLeave.enabled) {
96
+ player._startEmptyTimeout();
97
+ }
98
+ } else {
99
+ player._cancelEmptyTimeout();
100
+
101
+ if (player.autoPause.enabled && player._autoPaused && player.paused) {
102
+ player._autoPaused = false;
103
+ player.resume().then(success => {
104
+ if (success) {
105
+ player.emit('autoResume', memberCount);
106
+ log.info('PLAYER', `Auto-resumed (${memberCount} users in channel)`);
107
+ }
108
+ }).catch(err => {
109
+ log.error('PLAYER', `Auto-resume failed: ${err.message}`);
110
+ });
111
+ }
112
+ }
113
+ }
114
+ });
115
+ }
116
+
117
+ async create(guildId, voiceChannelId, textChannelId) {
118
+ if (this.players.has(guildId)) {
119
+ const existing = this.players.get(guildId);
120
+ if (existing.voiceChannelId !== voiceChannelId) {
121
+ existing.voiceChannelId = voiceChannelId;
122
+ if (existing.connected) {
123
+ existing.disconnect();
124
+ }
125
+ }
126
+ return existing;
127
+ }
128
+
129
+ const player = new Player(this, {
130
+ guildId,
131
+ voiceChannelId,
132
+ textChannelId,
133
+ volume: this.defaultVolume,
134
+ maxPreviousTracks: this.maxPreviousTracks,
135
+ autoLeave: this.autoLeave,
136
+ autoPause: this.autoPause,
137
+ autoplay: this.autoplay
138
+ });
139
+
140
+ player.on('destroy', () => {
141
+ this.players.delete(guildId);
142
+ this.emit('playerDestroy', player);
143
+ });
144
+
145
+ this.players.set(guildId, player);
146
+ this.emit('playerCreate', player);
147
+
148
+ log.info('MANAGER', `Created player for guild ${guildId}`);
149
+ return player;
150
+ }
151
+
152
+ get(guildId) {
153
+ return this.players.get(guildId);
154
+ }
155
+
156
+ destroy(guildId) {
157
+ const player = this.players.get(guildId);
158
+ if (player) {
159
+ player.destroy();
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ _isProviderEnabled(provider) {
166
+ return this.config.providers?.[provider]?.enabled !== false;
167
+ }
168
+
169
+ async search(query, options = {}) {
170
+ const source = options.source || this._detectSource(query) || 'youtube';
171
+ const limit = options.limit || 10;
172
+
173
+ log.info('MANAGER', `Search: "${query}" (source: ${source}, limit: ${limit})`);
174
+
175
+ try {
176
+ let result;
177
+
178
+ switch (source) {
179
+ case 'youtube':
180
+ case 'yt':
181
+ if (!this._isProviderEnabled('youtube')) {
182
+ throw new Error('YouTube provider is disabled');
183
+ }
184
+ result = await youtube.search(query, limit, this.config);
185
+ break;
186
+
187
+ case 'spotify':
188
+ case 'sp':
189
+ if (!this._isProviderEnabled('spotify')) {
190
+ throw new Error('Spotify provider is disabled');
191
+ }
192
+ if (!this.config.spotify?.clientId) {
193
+ throw new Error('Spotify credentials not configured');
194
+ }
195
+ result = await spotify.search(query, limit, this.config);
196
+ break;
197
+
198
+ case 'soundcloud':
199
+ case 'sc':
200
+ if (!this._isProviderEnabled('soundcloud')) {
201
+ throw new Error('SoundCloud provider is disabled');
202
+ }
203
+ result = await soundcloud.search(query, limit, this.config);
204
+ break;
205
+
206
+ default:
207
+ if (!this._isProviderEnabled('youtube')) {
208
+ throw new Error('YouTube provider is disabled');
209
+ }
210
+ result = await youtube.search(query, limit, this.config);
211
+ }
212
+
213
+ return {
214
+ loadType: result.tracks.length > 0 ? 'search' : 'empty',
215
+ tracks: result.tracks,
216
+ source: result.source
217
+ };
218
+ } catch (error) {
219
+ log.error('MANAGER', `Search error: ${error.message}`);
220
+ return {
221
+ loadType: 'error',
222
+ tracks: [],
223
+ error: error.message
224
+ };
225
+ }
226
+ }
227
+
228
+ async getInfo(id, source = 'youtube') {
229
+ try {
230
+ switch (source) {
231
+ case 'youtube':
232
+ case 'yt':
233
+ if (!this._isProviderEnabled('youtube')) {
234
+ throw new Error('YouTube provider is disabled');
235
+ }
236
+ return await youtube.getInfo(id, this.config);
237
+
238
+ case 'spotify':
239
+ case 'sp':
240
+ if (!this._isProviderEnabled('spotify')) {
241
+ throw new Error('Spotify provider is disabled');
242
+ }
243
+ return await spotify.getInfo(id, this.config);
244
+
245
+ case 'soundcloud':
246
+ case 'sc':
247
+ if (!this._isProviderEnabled('soundcloud')) {
248
+ throw new Error('SoundCloud provider is disabled');
249
+ }
250
+ return await soundcloud.getInfo(id, this.config);
251
+
252
+ default:
253
+ if (!this._isProviderEnabled('youtube')) {
254
+ throw new Error('YouTube provider is disabled');
255
+ }
256
+ return await youtube.getInfo(id, this.config);
257
+ }
258
+ } catch (error) {
259
+ log.error('MANAGER', `GetInfo error: ${error.message}`);
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ async resolve(query) {
265
+ const detected = this._detectSource(query);
266
+
267
+ if (detected) {
268
+ const match = this._extractId(query, detected);
269
+ if (match) {
270
+ try {
271
+ const track = await this.getInfo(match.id, detected);
272
+ if (detected === 'spotify') {
273
+ track._resolvedId = await this._resolveSpotifyToYouTube(track);
274
+ }
275
+ return {
276
+ loadType: 'track',
277
+ tracks: [track]
278
+ };
279
+ } catch (error) {
280
+ log.error('MANAGER', `Resolve error: ${error.message}`);
281
+ }
282
+ }
283
+ }
284
+
285
+ return this.search(query);
286
+ }
287
+
288
+ async _resolveSpotifyToYouTube(track) {
289
+ const videoId = await spotify.resolveToYouTube(track.id, this.config);
290
+ return videoId;
291
+ }
292
+
293
+ _detectSource(input) {
294
+ if (!input || typeof input !== 'string') return null;
295
+
296
+ if (/youtube\.com\/playlist\?list=/.test(input)) return 'youtube_playlist';
297
+ if (/open\.spotify\.com\/playlist\//.test(input)) return 'spotify_playlist';
298
+ if (/open\.spotify\.com\/album\//.test(input)) return 'spotify_album';
299
+
300
+ const patterns = {
301
+ youtube: [
302
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
303
+ /(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
304
+ /(?:music\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/
305
+ ],
306
+ spotify: [
307
+ /open\.spotify\.com\/track\/([a-zA-Z0-9]+)/,
308
+ /spotify:track:([a-zA-Z0-9]+)/
309
+ ],
310
+ soundcloud: [
311
+ /soundcloud\.com\/([^\/]+\/[^\/\?]+)/,
312
+ /api\.soundcloud\.com\/tracks\/(\d+)/
313
+ ]
314
+ };
315
+
316
+ for (const [source, regexes] of Object.entries(patterns)) {
317
+ for (const regex of regexes) {
318
+ if (regex.test(input)) {
319
+ return source;
320
+ }
321
+ }
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ _extractId(input, source) {
328
+ const patterns = {
329
+ youtube: /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|music\.youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
330
+ youtube_playlist: /youtube\.com\/playlist\?list=([a-zA-Z0-9_-]+)/,
331
+ spotify: /(?:open\.spotify\.com\/track\/|spotify:track:)([a-zA-Z0-9]+)/,
332
+ spotify_playlist: /open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/,
333
+ spotify_album: /open\.spotify\.com\/album\/([a-zA-Z0-9]+)/,
334
+ soundcloud: /soundcloud\.com\/([^\/]+\/[^\/\?]+)|api\.soundcloud\.com\/tracks\/(\d+)/
335
+ };
336
+
337
+ const pattern = patterns[source];
338
+ if (!pattern) return null;
339
+
340
+ const match = input.match(pattern);
341
+ if (!match) return null;
342
+
343
+ return {
344
+ source,
345
+ id: match[1] || match[2]
346
+ };
347
+ }
348
+
349
+ async loadPlaylist(url) {
350
+ const detected = this._detectSource(url);
351
+ if (!detected) {
352
+ throw new Error('Invalid playlist URL');
353
+ }
354
+
355
+ const match = this._extractId(url, detected);
356
+ if (!match) {
357
+ throw new Error('Could not extract playlist ID');
358
+ }
359
+
360
+ log.info('MANAGER', `Loading playlist: ${match.id} (source: ${detected})`);
361
+
362
+ try {
363
+ let playlist;
364
+
365
+ switch (detected) {
366
+ case 'youtube_playlist':
367
+ if (!this._isProviderEnabled('youtube')) {
368
+ throw new Error('YouTube provider is disabled');
369
+ }
370
+ playlist = await youtube.getPlaylist(match.id, this.config);
371
+ break;
372
+
373
+ case 'spotify_playlist':
374
+ if (!this._isProviderEnabled('spotify')) {
375
+ throw new Error('Spotify provider is disabled');
376
+ }
377
+ if (!this.config.spotify?.clientId) {
378
+ throw new Error('Spotify credentials not configured');
379
+ }
380
+ playlist = await spotify.getPlaylist(match.id, this.config);
381
+ break;
382
+
383
+ case 'spotify_album':
384
+ if (!this._isProviderEnabled('spotify')) {
385
+ throw new Error('Spotify provider is disabled');
386
+ }
387
+ if (!this.config.spotify?.clientId) {
388
+ throw new Error('Spotify credentials not configured');
389
+ }
390
+ playlist = await spotify.getAlbum(match.id, this.config);
391
+ break;
392
+
393
+ default:
394
+ throw new Error('URL is not a playlist');
395
+ }
396
+
397
+ return {
398
+ loadType: 'playlist',
399
+ playlist: {
400
+ id: playlist.id,
401
+ title: playlist.title,
402
+ author: playlist.author,
403
+ thumbnail: playlist.thumbnail,
404
+ source: playlist.source
405
+ },
406
+ tracks: playlist.tracks
407
+ };
408
+ } catch (error) {
409
+ log.error('MANAGER', `Playlist load error: ${error.message}`);
410
+ return {
411
+ loadType: 'error',
412
+ tracks: [],
413
+ error: error.message
414
+ };
415
+ }
416
+ }
417
+
418
+ async getRelated(track, limit = 5) {
419
+ log.info('MANAGER', `Getting related tracks for: ${track.title}`);
420
+
421
+ try {
422
+ if (track.source === 'spotify') {
423
+ const result = await spotify.getRecommendations(track.id, limit, this.config);
424
+ return result;
425
+ } else {
426
+ const videoId = track._resolvedId || track.id;
427
+ const result = await youtube.getRelated(videoId, limit, this.config);
428
+ return result;
429
+ }
430
+ } catch (error) {
431
+ log.error('MANAGER', `Get related error: ${error.message}`);
432
+ return { tracks: [] };
433
+ }
434
+ }
435
+
436
+ destroyAll() {
437
+ for (const [guildId, player] of this.players) {
438
+ player.destroy();
439
+ }
440
+ this.players.clear();
441
+ log.info('MANAGER', 'All players destroyed');
442
+ }
443
+
444
+ getStats() {
445
+ return {
446
+ players: this.players.size,
447
+ playingPlayers: Array.from(this.players.values()).filter(p => p.playing).length,
448
+ memory: process.memoryUsage()
449
+ };
450
+ }
451
+ }
452
+
453
+ module.exports = Manager;