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.
- package/LICENSE +21 -0
- package/README.md +430 -0
- package/bin/streamify.js +84 -0
- package/docs/README.md +31 -0
- package/docs/automation.md +186 -0
- package/docs/configuration.md +169 -0
- package/docs/discord/events.md +206 -0
- package/docs/discord/manager.md +180 -0
- package/docs/discord/player.md +180 -0
- package/docs/discord/queue.md +197 -0
- package/docs/examples/advanced-bot.md +391 -0
- package/docs/examples/basic-bot.md +182 -0
- package/docs/examples/lavalink.md +156 -0
- package/docs/filters.md +309 -0
- package/docs/http/endpoints.md +199 -0
- package/docs/http/server.md +172 -0
- package/docs/quick-start.md +92 -0
- package/docs/sources.md +141 -0
- package/docs/sponsorblock.md +95 -0
- package/index.d.ts +350 -0
- package/index.js +252 -0
- package/package.json +57 -0
- package/silence.ogg +0 -0
- package/src/cache/index.js +61 -0
- package/src/config.js +133 -0
- package/src/discord/Manager.js +453 -0
- package/src/discord/Player.js +658 -0
- package/src/discord/Queue.js +129 -0
- package/src/discord/Stream.js +252 -0
- package/src/filters/ffmpeg.js +270 -0
- package/src/providers/soundcloud.js +166 -0
- package/src/providers/spotify.js +216 -0
- package/src/providers/youtube.js +320 -0
- package/src/server.js +318 -0
- package/src/utils/logger.js +139 -0
- package/src/utils/stream.js +108 -0
|
@@ -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;
|