enerthya.dev-audio-server 1.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/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # enerthya.dev-audio-server
2
+
3
+ Part of the Enerthya audio system.
4
+
5
+ See `src/index.js` for full API documentation.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "enerthya.dev-audio-server",
3
+ "version": "1.0.0",
4
+ "description": "Enerthya Audio Server \u2014 Lavalink-compatible WebSocket audio server in Node.js. Uses enerthya.dev-audio-core for stream processing. Runs on port 80 (Shardcloud compatible).",
5
+ "main": "src/index.js",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=18.0.0"
9
+ },
10
+ "keywords": [
11
+ "enerthya",
12
+ "audio",
13
+ "server",
14
+ "lavalink",
15
+ "websocket"
16
+ ],
17
+ "dependencies": {
18
+ "enerthya.dev-audio-core": "1.0.0",
19
+ "ws": "^8.18.0"
20
+ },
21
+ "scripts": {
22
+ "test": "node test.js"
23
+ }
24
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * constants/index.js — Protocolo WebSocket do enerthya.dev-audio-server
3
+ * Compatível com o protocolo do Lavalink v4.
4
+ */
5
+
6
+ // Opcodes Server → Client
7
+ const Op = {
8
+ READY: "ready", // servidor pronto, retorna sessionId
9
+ PLAYER_UPDATE: "playerUpdate", // posição atual + estado do player
10
+ STATS: "stats", // estatísticas do servidor
11
+ EVENT: "event", // eventos de track
12
+
13
+ // Opcodes Client → Server
14
+ PLAY: "play",
15
+ STOP: "stop",
16
+ PAUSE: "pause",
17
+ SEEK: "seek",
18
+ VOLUME: "volume",
19
+ SKIP: "skip",
20
+ QUEUE_ADD: "queueAdd",
21
+ QUEUE_REMOVE: "queueRemove",
22
+ QUEUE_CLEAR: "queueClear",
23
+ QUEUE_SHUFFLE: "queueShuffle",
24
+ QUEUE_GET: "queueGet",
25
+ DESTROY: "destroy",
26
+ SEARCH: "search",
27
+ LOAD: "load",
28
+ };
29
+
30
+ const TrackEvent = {
31
+ START: "TrackStartEvent",
32
+ END: "TrackEndEvent",
33
+ EXCEPTION: "TrackExceptionEvent",
34
+ STUCK: "TrackStuckEvent",
35
+ };
36
+
37
+ const EndReason = {
38
+ FINISHED: "finished",
39
+ LOAD_FAILED: "loadFailed",
40
+ STOPPED: "stopped",
41
+ REPLACED: "replaced",
42
+ CLEANUP: "cleanup",
43
+ };
44
+
45
+ const PlayerState = {
46
+ IDLE: "IDLE",
47
+ PLAYING: "PLAYING",
48
+ PAUSED: "PAUSED",
49
+ LOADING: "LOADING",
50
+ };
51
+
52
+ const Defaults = {
53
+ PORT: 2333,
54
+ PASSWORD: "enerthya",
55
+ VOLUME: 100,
56
+ MAX_QUEUE_SIZE: 500,
57
+ STATS_INTERVAL_MS: 60_000,
58
+ PLAYER_UPDATE_MS: 5_000,
59
+ };
60
+
61
+ module.exports = { Op, TrackEvent, EndReason, PlayerState, Defaults };
package/src/index.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * enerthya.dev-audio-server
3
+ *
4
+ * Servidor de áudio WebSocket da Enerthya.
5
+ * Equivalente ao Lavalink (Java) — implementado em Node.js.
6
+ *
7
+ * Uso standalone:
8
+ * const { AudioServer } = require("enerthya.dev-audio-server");
9
+ * new AudioServer({ password: "minha-senha", port: 2333 });
10
+ *
11
+ * Uso integrado na dashboard (porta 80 existente):
12
+ * const { AudioServer } = require("enerthya.dev-audio-server");
13
+ * new AudioServer({ password: process.env.AUDIO_PASSWORD, httpServer: expressApp });
14
+ */
15
+
16
+ const { AudioServer } = require("./server/AudioServer");
17
+ const { GuildPlayer, RepeatMode } = require("./queue/GuildPlayer");
18
+ const { Op, TrackEvent, EndReason, PlayerState, Defaults } = require("./constants");
19
+
20
+ module.exports = {
21
+ AudioServer,
22
+ GuildPlayer,
23
+ RepeatMode,
24
+ Op,
25
+ TrackEvent,
26
+ EndReason,
27
+ PlayerState,
28
+ Defaults,
29
+ };
@@ -0,0 +1,238 @@
1
+ /**
2
+ * queue/GuildPlayer.js — Player e fila por guild
3
+ *
4
+ * Gerencia estado de reprodução, fila de tracks e decode
5
+ * para uma guild específica. Usa enerthya.dev-audio-core para streams.
6
+ */
7
+
8
+ const { EventEmitter } = require("events");
9
+ const { AudioDecoder } = require("enerthya.dev-audio-core");
10
+ const { PlayerState, EndReason, Defaults } = require("../constants");
11
+
12
+ const RepeatMode = { NONE: "none", TRACK: "track", QUEUE: "queue" };
13
+
14
+ class GuildPlayer extends EventEmitter {
15
+ /**
16
+ * @param {string} guildId
17
+ * @param {object} server — referência ao AudioServer
18
+ */
19
+ constructor(guildId, server) {
20
+ super();
21
+ this.guildId = guildId;
22
+ this.server = server;
23
+
24
+ this.queue = [];
25
+ this.current = null;
26
+ this.state = PlayerState.IDLE;
27
+ this.repeat = RepeatMode.NONE;
28
+ this.volume = Defaults.VOLUME;
29
+ this.position = 0;
30
+ this.paused = false;
31
+
32
+ this._decoder = new AudioDecoder();
33
+ this._startedAt = null;
34
+ this._pausedAt = null;
35
+ this._updateInterval = null;
36
+
37
+ // Eventos do decoder
38
+ this._decoder.on("end", () => this._onTrackEnd());
39
+ this._decoder.on("error", (err) => this._onTrackError(err));
40
+ this._decoder.on("data", (chunk) => this.emit("audioData", chunk));
41
+ }
42
+
43
+ // ── Controles ─────────────────────────────────────────────────────────────
44
+
45
+ async play(track, opts = {}) {
46
+ if (!track) return;
47
+
48
+ this.state = PlayerState.LOADING;
49
+ this.current = track;
50
+ this.position = opts.startTime || 0;
51
+
52
+ this.emit("trackStart", track);
53
+
54
+ try {
55
+ const { getAudioStream } = require("enerthya.dev-audio-core");
56
+ const stream = await getAudioStream(track);
57
+
58
+ this._decoder.start(stream, {
59
+ seek: this.position,
60
+ volume: this.volume,
61
+ });
62
+
63
+ this.state = PlayerState.PLAYING;
64
+ this.paused = false;
65
+ this._startedAt = Date.now() - this.position;
66
+ this._startUpdateInterval();
67
+
68
+ } catch (err) {
69
+ this.state = PlayerState.IDLE;
70
+ this.emit("trackException", track, err.message);
71
+ this._playNext();
72
+ }
73
+ }
74
+
75
+ pause() {
76
+ if (this.state !== PlayerState.PLAYING) return;
77
+ this._decoder.stop();
78
+ this.paused = true;
79
+ this.state = PlayerState.PAUSED;
80
+ this._pausedAt = Date.now();
81
+ this._stopUpdateInterval();
82
+ }
83
+
84
+ resume() {
85
+ if (!this.paused || !this.current) return;
86
+ const pauseDuration = Date.now() - (this._pausedAt || Date.now());
87
+ this._startedAt = (this._startedAt || Date.now()) + pauseDuration;
88
+ this.paused = false;
89
+ this.play(this.current, { startTime: this.position });
90
+ }
91
+
92
+ stop() {
93
+ this._decoder.stop();
94
+ this._stopUpdateInterval();
95
+ this.state = PlayerState.IDLE;
96
+ this.current = null;
97
+ this.position = 0;
98
+ this.paused = false;
99
+ this.emit("playerStop");
100
+ }
101
+
102
+ skip() {
103
+ this._decoder.stop();
104
+ this._onTrackEnd(EndReason.REPLACED);
105
+ }
106
+
107
+ seek(positionMs) {
108
+ if (!this.current) return;
109
+ this.position = positionMs;
110
+ this._startedAt = Date.now() - positionMs;
111
+ const currentTrack = this.current;
112
+ this._decoder.stop();
113
+ this.play(currentTrack, { startTime: positionMs });
114
+ }
115
+
116
+ setVolume(vol) {
117
+ this.volume = Math.max(0, Math.min(1000, vol));
118
+ // Reinicia o decoder com novo volume se estiver tocando
119
+ if (this.state === PlayerState.PLAYING && this.current) {
120
+ const track = this.current;
121
+ const pos = this.getPosition();
122
+ this._decoder.stop();
123
+ this.play(track, { startTime: pos });
124
+ }
125
+ }
126
+
127
+ // ── Fila ──────────────────────────────────────────────────────────────────
128
+
129
+ addToQueue(track) {
130
+ if (this.queue.length >= Defaults.MAX_QUEUE_SIZE) return false;
131
+ const toAdd = Array.isArray(track) ? track : [track];
132
+ this.queue.push(...toAdd.slice(0, Defaults.MAX_QUEUE_SIZE - this.queue.length));
133
+ return true;
134
+ }
135
+
136
+ removeFromQueue(index) {
137
+ if (index < 0 || index >= this.queue.length) return null;
138
+ return this.queue.splice(index, 1)[0];
139
+ }
140
+
141
+ clearQueue() {
142
+ this.queue = [];
143
+ }
144
+
145
+ shuffleQueue() {
146
+ for (let i = this.queue.length - 1; i > 0; i--) {
147
+ const j = Math.floor(Math.random() * (i + 1));
148
+ [this.queue[i], this.queue[j]] = [this.queue[j], this.queue[i]];
149
+ }
150
+ }
151
+
152
+ setRepeat(mode) {
153
+ if (Object.values(RepeatMode).includes(mode)) this.repeat = mode;
154
+ }
155
+
156
+ // ── Posição ───────────────────────────────────────────────────────────────
157
+
158
+ getPosition() {
159
+ if (!this._startedAt || this.paused) return this.position;
160
+ return Date.now() - this._startedAt;
161
+ }
162
+
163
+ // ── Internos ──────────────────────────────────────────────────────────────
164
+
165
+ _onTrackEnd(reason = EndReason.FINISHED) {
166
+ this._stopUpdateInterval();
167
+ const ended = this.current;
168
+
169
+ if (this.repeat === RepeatMode.TRACK && ended && reason === EndReason.FINISHED) {
170
+ this.play(ended);
171
+ return;
172
+ }
173
+
174
+ this.emit("trackEnd", ended, reason);
175
+ this._playNext();
176
+ }
177
+
178
+ _onTrackError(err) {
179
+ this._stopUpdateInterval();
180
+ this.emit("trackException", this.current, err.message);
181
+ this._playNext();
182
+ }
183
+
184
+ _playNext() {
185
+ if (this.queue.length === 0) {
186
+ this.state = PlayerState.IDLE;
187
+ this.current = null;
188
+ this.emit("queueEnd");
189
+ return;
190
+ }
191
+
192
+ const next = this.queue.shift();
193
+
194
+ // Modo repeat queue — coloca a track anterior no fim
195
+ if (this.repeat === RepeatMode.QUEUE && this.current) {
196
+ this.queue.push(this.current);
197
+ }
198
+
199
+ this.play(next);
200
+ }
201
+
202
+ _startUpdateInterval() {
203
+ this._stopUpdateInterval();
204
+ this._updateInterval = setInterval(() => {
205
+ this.position = this.getPosition();
206
+ this.emit("playerUpdate", { position: this.position, state: this.state });
207
+ }, Defaults.PLAYER_UPDATE_MS);
208
+ if (this._updateInterval.unref) this._updateInterval.unref();
209
+ }
210
+
211
+ _stopUpdateInterval() {
212
+ if (this._updateInterval) {
213
+ clearInterval(this._updateInterval);
214
+ this._updateInterval = null;
215
+ }
216
+ }
217
+
218
+ destroy() {
219
+ this.stop();
220
+ this._decoder.removeAllListeners();
221
+ this.removeAllListeners();
222
+ }
223
+
224
+ toJSON() {
225
+ return {
226
+ guildId: this.guildId,
227
+ state: this.state,
228
+ current: this.current,
229
+ queue: this.queue,
230
+ repeat: this.repeat,
231
+ volume: this.volume,
232
+ position: this.getPosition(),
233
+ paused: this.paused,
234
+ };
235
+ }
236
+ }
237
+
238
+ module.exports = { GuildPlayer, RepeatMode };
@@ -0,0 +1,344 @@
1
+ /**
2
+ * server/AudioServer.js — Servidor WebSocket de áudio
3
+ *
4
+ * Equivalente ao servidor Lavalink em Java, mas em Node.js.
5
+ * Aceita conexões WebSocket de bots Discord autenticados por senha.
6
+ * Cada conexão representa um bot (identificado pelo userId do bot).
7
+ *
8
+ * Protocolo:
9
+ * - Autenticação via header "Authorization" na abertura do WS
10
+ * - Mensagens JSON com campo "op" definindo a operação
11
+ * - Compatível com o protocolo do Lavalink v4
12
+ */
13
+
14
+ const { WebSocketServer, WebSocket } = require("ws");
15
+ const crypto = require("crypto");
16
+ const { GuildPlayer } = require("../queue/GuildPlayer");
17
+ const { Op, TrackEvent, EndReason, PlayerState, Defaults } = require("../constants");
18
+ const { loadItem } = require("enerthya.dev-audio-core");
19
+
20
+ class AudioServer {
21
+ /**
22
+ * @param {object} opts
23
+ * @param {string} [opts.password] — senha de autenticação (padrão: "enerthya")
24
+ * @param {http.Server} [opts.httpServer] — servidor HTTP existente para attach (porta 80)
25
+ * @param {number} [opts.port] — porta standalone (padrão: 2333)
26
+ * @param {Function} [opts.logger] — função de log (padrão: console.log)
27
+ */
28
+ constructor(opts = {}) {
29
+ this.password = opts.password || process.env.AUDIO_PASSWORD || Defaults.PASSWORD;
30
+ this.logger = opts.logger || console.log;
31
+
32
+ // sessions: sessionId → { ws, userId, players: Map<guildId, GuildPlayer> }
33
+ this.sessions = new Map();
34
+
35
+ // Cria o WebSocket server
36
+ if (opts.httpServer) {
37
+ // Attach no servidor HTTP existente (porta 80 da dashboard)
38
+ this.wss = new WebSocketServer({ server: opts.httpServer, path: "/audio" });
39
+ } else {
40
+ // Standalone
41
+ const port = opts.port || Defaults.PORT;
42
+ this.wss = new WebSocketServer({ port, path: "/audio" });
43
+ this.logger(`[AudioServer] Rodando standalone na porta ${port}`);
44
+ }
45
+
46
+ this._setupWss();
47
+ this._startStatsInterval();
48
+ }
49
+
50
+ // ── Setup WebSocket ────────────────────────────────────────────────────────
51
+
52
+ _setupWss() {
53
+ this.wss.on("connection", (ws, req) => {
54
+ // Autenticação via header Authorization
55
+ const auth = req.headers["authorization"];
56
+ if (auth !== this.password) {
57
+ ws.close(4001, "Unauthorized");
58
+ return;
59
+ }
60
+
61
+ const userId = req.headers["user-id"] || "unknown";
62
+ const sessionId = crypto.randomBytes(8).toString("hex");
63
+
64
+ const session = {
65
+ ws,
66
+ userId,
67
+ sessionId,
68
+ players: new Map(),
69
+ connectedAt: Date.now(),
70
+ };
71
+
72
+ this.sessions.set(sessionId, session);
73
+ this.logger(`[AudioServer] Conexão estabelecida — userId: ${userId} sessionId: ${sessionId}`);
74
+
75
+ // Envia READY
76
+ this._send(ws, {
77
+ op: Op.READY,
78
+ resumed: false,
79
+ sessionId,
80
+ });
81
+
82
+ ws.on("message", (data) => {
83
+ try {
84
+ const msg = JSON.parse(data.toString());
85
+ this._handleMessage(session, msg);
86
+ } catch (err) {
87
+ this.logger(`[AudioServer] Mensagem inválida: ${err.message}`);
88
+ }
89
+ });
90
+
91
+ ws.on("close", () => {
92
+ this.logger(`[AudioServer] Conexão encerrada — sessionId: ${sessionId}`);
93
+ // Destroi todos os players desta sessão
94
+ for (const player of session.players.values()) {
95
+ player.destroy();
96
+ }
97
+ this.sessions.delete(sessionId);
98
+ });
99
+
100
+ ws.on("error", (err) => {
101
+ this.logger(`[AudioServer] Erro WS — ${err.message}`);
102
+ });
103
+ });
104
+ }
105
+
106
+ // ── Handler de mensagens ───────────────────────────────────────────────────
107
+
108
+ async _handleMessage(session, msg) {
109
+ const { op, guildId } = msg;
110
+ if (!op) return;
111
+
112
+ switch (op) {
113
+
114
+ case Op.LOAD: {
115
+ const result = await loadItem(msg.query, {
116
+ limit: msg.limit || 1,
117
+ requesterId: session.userId,
118
+ }).catch((err) => ({ loadType: "error", tracks: [], error: err.message }));
119
+ this._send(session.ws, { op: "loadResult", guildId, result });
120
+ break;
121
+ }
122
+
123
+ case Op.SEARCH: {
124
+ const result = await loadItem(msg.query, {
125
+ limit: msg.limit || 5,
126
+ requesterId: session.userId,
127
+ }).catch(() => ({ loadType: "empty", tracks: [] }));
128
+ this._send(session.ws, { op: "searchResult", guildId, result });
129
+ break;
130
+ }
131
+
132
+ case Op.PLAY: {
133
+ if (!guildId || !msg.track) return;
134
+ const player = this._getOrCreatePlayer(session, guildId);
135
+
136
+ if (msg.noReplace && player.state === PlayerState.PLAYING) return;
137
+
138
+ // Adiciona à fila ou toca imediatamente
139
+ if (msg.addToQueue && (player.state === PlayerState.PLAYING || player.queue.length > 0)) {
140
+ player.addToQueue(msg.track);
141
+ this._send(session.ws, { op: "queueUpdated", guildId, queue: player.queue });
142
+ } else {
143
+ if (msg.track) {
144
+ player.clearQueue();
145
+ await player.play(msg.track, { startTime: msg.startTime || 0 });
146
+ }
147
+ }
148
+ break;
149
+ }
150
+
151
+ case Op.QUEUE_ADD: {
152
+ if (!guildId || !msg.track) return;
153
+ const player = this._getOrCreatePlayer(session, guildId);
154
+ player.addToQueue(msg.track);
155
+ this._send(session.ws, { op: "queueUpdated", guildId, queue: player.queue });
156
+ break;
157
+ }
158
+
159
+ case Op.STOP: {
160
+ const player = session.players.get(guildId);
161
+ if (player) player.stop();
162
+ break;
163
+ }
164
+
165
+ case Op.PAUSE: {
166
+ const player = session.players.get(guildId);
167
+ if (!player) return;
168
+ msg.pause ? player.pause() : player.resume();
169
+ this._send(session.ws, {
170
+ op: Op.PLAYER_UPDATE, guildId,
171
+ state: { position: player.getPosition(), paused: player.paused },
172
+ });
173
+ break;
174
+ }
175
+
176
+ case Op.SEEK: {
177
+ const player = session.players.get(guildId);
178
+ if (player && msg.position !== undefined) player.seek(msg.position);
179
+ break;
180
+ }
181
+
182
+ case Op.VOLUME: {
183
+ const player = session.players.get(guildId);
184
+ if (player && msg.volume !== undefined) player.setVolume(msg.volume);
185
+ break;
186
+ }
187
+
188
+ case Op.SKIP: {
189
+ const player = session.players.get(guildId);
190
+ if (player) player.skip();
191
+ break;
192
+ }
193
+
194
+ case Op.QUEUE_REMOVE: {
195
+ const player = session.players.get(guildId);
196
+ if (player && msg.index !== undefined) {
197
+ player.removeFromQueue(msg.index);
198
+ this._send(session.ws, { op: "queueUpdated", guildId, queue: player.queue });
199
+ }
200
+ break;
201
+ }
202
+
203
+ case Op.QUEUE_CLEAR: {
204
+ const player = session.players.get(guildId);
205
+ if (player) {
206
+ player.clearQueue();
207
+ this._send(session.ws, { op: "queueUpdated", guildId, queue: [] });
208
+ }
209
+ break;
210
+ }
211
+
212
+ case Op.QUEUE_SHUFFLE: {
213
+ const player = session.players.get(guildId);
214
+ if (player) {
215
+ player.shuffleQueue();
216
+ this._send(session.ws, { op: "queueUpdated", guildId, queue: player.queue });
217
+ }
218
+ break;
219
+ }
220
+
221
+ case Op.QUEUE_GET: {
222
+ const player = session.players.get(guildId);
223
+ this._send(session.ws, {
224
+ op: "queueResult",
225
+ guildId,
226
+ player: player ? player.toJSON() : null,
227
+ });
228
+ break;
229
+ }
230
+
231
+ case Op.DESTROY: {
232
+ const player = session.players.get(guildId);
233
+ if (player) {
234
+ player.destroy();
235
+ session.players.delete(guildId);
236
+ }
237
+ break;
238
+ }
239
+ }
240
+ }
241
+
242
+ // ── Player helpers ─────────────────────────────────────────────────────────
243
+
244
+ _getOrCreatePlayer(session, guildId) {
245
+ if (session.players.has(guildId)) return session.players.get(guildId);
246
+
247
+ const player = new GuildPlayer(guildId, this);
248
+
249
+ // Eventos do player → mensagens WS para o client
250
+ player.on("trackStart", (track) => {
251
+ this._send(session.ws, {
252
+ op: Op.EVENT,
253
+ type: TrackEvent.START,
254
+ guildId,
255
+ track,
256
+ });
257
+ });
258
+
259
+ player.on("trackEnd", (track, reason) => {
260
+ this._send(session.ws, {
261
+ op: Op.EVENT,
262
+ type: TrackEvent.END,
263
+ guildId,
264
+ track,
265
+ reason,
266
+ });
267
+ });
268
+
269
+ player.on("trackException", (track, error) => {
270
+ this._send(session.ws, {
271
+ op: Op.EVENT,
272
+ type: TrackEvent.EXCEPTION,
273
+ guildId,
274
+ track,
275
+ exception: { message: error, severity: "common" },
276
+ });
277
+ });
278
+
279
+ player.on("playerUpdate", ({ position }) => {
280
+ this._send(session.ws, {
281
+ op: Op.PLAYER_UPDATE,
282
+ guildId,
283
+ state: { time: Date.now(), position, connected: true },
284
+ });
285
+ });
286
+
287
+ // Stream de áudio PCM → enviado via WS para o bot tocar com @discordjs/voice
288
+ player.on("audioData", (chunk) => {
289
+ if (session.ws.readyState === WebSocket.OPEN) {
290
+ session.ws.send(chunk); // Buffer binário — bot identifica pelo guildId atual
291
+ }
292
+ });
293
+
294
+ session.players.set(guildId, player);
295
+ return player;
296
+ }
297
+
298
+ // ── Stats ──────────────────────────────────────────────────────────────────
299
+
300
+ _startStatsInterval() {
301
+ const interval = setInterval(() => {
302
+ const stats = this._getStats();
303
+ for (const { ws } of this.sessions.values()) {
304
+ this._send(ws, { op: Op.STATS, ...stats });
305
+ }
306
+ }, Defaults.STATS_INTERVAL_MS);
307
+ if (interval.unref) interval.unref();
308
+ }
309
+
310
+ _getStats() {
311
+ const mem = process.memoryUsage();
312
+ let players = 0, playingPlayers = 0;
313
+ for (const session of this.sessions.values()) {
314
+ players += session.players.size;
315
+ for (const p of session.players.values()) {
316
+ if (p.state === PlayerState.PLAYING) playingPlayers++;
317
+ }
318
+ }
319
+ return {
320
+ players, playingPlayers,
321
+ memory: {
322
+ free: Math.round((require("os").freemem()) / 1024 / 1024),
323
+ used: Math.round(mem.heapUsed / 1024 / 1024),
324
+ allocated: Math.round(mem.heapTotal / 1024 / 1024),
325
+ reservable: Math.round(require("os").totalmem() / 1024 / 1024),
326
+ },
327
+ cpu: { cores: require("os").cpus().length, systemLoad: require("os").loadavg()[0] },
328
+ uptime: Math.floor(process.uptime()),
329
+ };
330
+ }
331
+
332
+ // ── Utils ──────────────────────────────────────────────────────────────────
333
+
334
+ _send(ws, payload) {
335
+ if (ws && ws.readyState === WebSocket.OPEN) {
336
+ ws.send(JSON.stringify(payload));
337
+ }
338
+ }
339
+
340
+ /** Retorna estatísticas do servidor para uso externo */
341
+ getStats() { return this._getStats(); }
342
+ }
343
+
344
+ module.exports = { AudioServer };
package/test.js ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * enerthya.dev-audio-server — test suite
3
+ * Run: node test.js
4
+ * Uses Node built-in assert only.
5
+ * Testa protocolo, constantes, GuildPlayer e estrutura do servidor.
6
+ * Não faz conexões reais de rede.
7
+ */
8
+
9
+ const assert = require("assert");
10
+ const { EventEmitter } = require("events");
11
+
12
+ const {
13
+ Op,
14
+ TrackEvent,
15
+ EndReason,
16
+ PlayerState,
17
+ Defaults,
18
+ RepeatMode,
19
+ } = require("./src/index");
20
+
21
+ const { GuildPlayer } = require("./src/queue/GuildPlayer");
22
+
23
+ let passed = 0;
24
+ let failed = 0;
25
+
26
+ function test(name, fn) {
27
+ try {
28
+ const result = fn();
29
+ if (result && typeof result.then === "function") {
30
+ result
31
+ .then(() => { console.log(` ✅ ${name}`); passed++; })
32
+ .catch((err) => { console.error(` ❌ ${name}\n ${err.message}`); failed++; });
33
+ return;
34
+ }
35
+ console.log(` ✅ ${name}`);
36
+ passed++;
37
+ } catch (err) {
38
+ console.error(` ❌ ${name}\n ${err.message}`);
39
+ failed++;
40
+ }
41
+ }
42
+
43
+ // ── Constantes / Protocolo ────────────────────────────────────────────────────
44
+
45
+ console.log("\n── Protocolo / Constantes ────────────────────────────────────────────────────");
46
+
47
+ test("Op contém todos os opcodes essenciais", () => {
48
+ const required = ["READY","PLAYER_UPDATE","STATS","EVENT","PLAY","STOP","PAUSE","SEEK","VOLUME","SKIP","DESTROY","LOAD","SEARCH"];
49
+ for (const op of required) {
50
+ assert.ok(Op[op], `Op.${op} deve existir`);
51
+ }
52
+ });
53
+
54
+ test("TrackEvent contém START, END, EXCEPTION, STUCK", () => {
55
+ assert.ok(TrackEvent.START);
56
+ assert.ok(TrackEvent.END);
57
+ assert.ok(TrackEvent.EXCEPTION);
58
+ assert.ok(TrackEvent.STUCK);
59
+ });
60
+
61
+ test("EndReason contém todos os motivos", () => {
62
+ assert.ok(EndReason.FINISHED);
63
+ assert.ok(EndReason.LOAD_FAILED);
64
+ assert.ok(EndReason.STOPPED);
65
+ assert.ok(EndReason.REPLACED);
66
+ assert.ok(EndReason.CLEANUP);
67
+ });
68
+
69
+ test("PlayerState contém IDLE, PLAYING, PAUSED, LOADING", () => {
70
+ assert.ok(PlayerState.IDLE);
71
+ assert.ok(PlayerState.PLAYING);
72
+ assert.ok(PlayerState.PAUSED);
73
+ assert.ok(PlayerState.LOADING);
74
+ });
75
+
76
+ test("Defaults.PORT é número positivo", () => {
77
+ assert.ok(typeof Defaults.PORT === "number");
78
+ assert.ok(Defaults.PORT > 0);
79
+ });
80
+
81
+ test("Defaults.PASSWORD é string não vazia", () => {
82
+ assert.ok(typeof Defaults.PASSWORD === "string");
83
+ assert.ok(Defaults.PASSWORD.length > 0);
84
+ });
85
+
86
+ test("Defaults.MAX_QUEUE_SIZE é número positivo", () => {
87
+ assert.ok(Defaults.MAX_QUEUE_SIZE > 0);
88
+ });
89
+
90
+ test("RepeatMode contém NONE, TRACK, QUEUE", () => {
91
+ assert.ok(RepeatMode.NONE !== undefined);
92
+ assert.ok(RepeatMode.TRACK !== undefined);
93
+ assert.ok(RepeatMode.QUEUE !== undefined);
94
+ assert.notStrictEqual(RepeatMode.NONE, RepeatMode.TRACK);
95
+ assert.notStrictEqual(RepeatMode.TRACK, RepeatMode.QUEUE);
96
+ });
97
+
98
+ // ── GuildPlayer — estrutura e fila ────────────────────────────────────────────
99
+
100
+ console.log("\n── GuildPlayer ───────────────────────────────────────────────────────────────");
101
+
102
+ function makeTrack(id, title = "Track") {
103
+ const { buildTrack } = require("enerthya.dev-audio-core");
104
+ return buildTrack({
105
+ identifier: id, title, author: "Autor",
106
+ uri: `https://youtube.com/watch?v=${id}`,
107
+ length: 180_000, sourceName: "youtube",
108
+ });
109
+ }
110
+
111
+ function makePlayer(guildId = "111") {
112
+ return new GuildPlayer(guildId, {});
113
+ }
114
+
115
+ test("GuildPlayer é instanciável", () => {
116
+ const p = makePlayer();
117
+ assert.ok(p instanceof EventEmitter);
118
+ assert.strictEqual(p.state, PlayerState.IDLE);
119
+ assert.strictEqual(p.guildId, "111");
120
+ });
121
+
122
+ test("estado inicial correto", () => {
123
+ const p = makePlayer();
124
+ assert.strictEqual(p.state, PlayerState.IDLE);
125
+ assert.strictEqual(p.current, null);
126
+ assert.strictEqual(p.paused, false);
127
+ assert.strictEqual(p.volume, Defaults.VOLUME);
128
+ assert.deepStrictEqual(p.queue, []);
129
+ });
130
+
131
+ test("addToQueue adiciona track", () => {
132
+ const p = makePlayer();
133
+ const t = makeTrack("abc", "Song A");
134
+ p.addToQueue(t);
135
+ assert.strictEqual(p.queue.length, 1);
136
+ assert.strictEqual(p.queue[0].info.title, "Song A");
137
+ });
138
+
139
+ test("addToQueue com array adiciona múltiplas tracks", () => {
140
+ const p = makePlayer();
141
+ p.addToQueue([makeTrack("a"), makeTrack("b"), makeTrack("c")]);
142
+ assert.strictEqual(p.queue.length, 3);
143
+ });
144
+
145
+ test("removeFromQueue remove pelo índice", () => {
146
+ const p = makePlayer();
147
+ p.addToQueue([makeTrack("a", "A"), makeTrack("b", "B"), makeTrack("c", "C")]);
148
+ const removed = p.removeFromQueue(1);
149
+ assert.strictEqual(removed.info.title, "B");
150
+ assert.strictEqual(p.queue.length, 2);
151
+ });
152
+
153
+ test("removeFromQueue retorna null para índice inválido", () => {
154
+ const p = makePlayer();
155
+ assert.strictEqual(p.removeFromQueue(0), null);
156
+ assert.strictEqual(p.removeFromQueue(-1), null);
157
+ assert.strictEqual(p.removeFromQueue(999), null);
158
+ });
159
+
160
+ test("clearQueue limpa a fila", () => {
161
+ const p = makePlayer();
162
+ p.addToQueue([makeTrack("a"), makeTrack("b")]);
163
+ p.clearQueue();
164
+ assert.deepStrictEqual(p.queue, []);
165
+ });
166
+
167
+ test("shuffleQueue não perde tracks", () => {
168
+ const p = makePlayer();
169
+ const tracks = [makeTrack("a"), makeTrack("b"), makeTrack("c"), makeTrack("d"), makeTrack("e")];
170
+ p.addToQueue(tracks);
171
+ p.shuffleQueue();
172
+ assert.strictEqual(p.queue.length, 5);
173
+ // Todas as tracks ainda presentes (pelo identifier)
174
+ const ids = p.queue.map((t) => t.info.identifier).sort();
175
+ assert.deepStrictEqual(ids, ["a","b","c","d","e"]);
176
+ });
177
+
178
+ test("setRepeat aceita modos válidos", () => {
179
+ const p = makePlayer();
180
+ p.setRepeat(RepeatMode.TRACK);
181
+ assert.strictEqual(p.repeat, RepeatMode.TRACK);
182
+ p.setRepeat(RepeatMode.QUEUE);
183
+ assert.strictEqual(p.repeat, RepeatMode.QUEUE);
184
+ p.setRepeat(RepeatMode.NONE);
185
+ assert.strictEqual(p.repeat, RepeatMode.NONE);
186
+ });
187
+
188
+ test("setRepeat ignora modo inválido", () => {
189
+ const p = makePlayer();
190
+ p.setRepeat(RepeatMode.NONE);
191
+ p.setRepeat("INVALID_MODE");
192
+ assert.strictEqual(p.repeat, RepeatMode.NONE);
193
+ });
194
+
195
+ test("setVolume respeita limites 0–1000", () => {
196
+ const p = makePlayer();
197
+ p.setVolume(500);
198
+ assert.strictEqual(p.volume, 500);
199
+ p.setVolume(-100);
200
+ assert.strictEqual(p.volume, 0);
201
+ p.setVolume(9999);
202
+ assert.strictEqual(p.volume, 1000);
203
+ });
204
+
205
+ test("addToQueue respeita MAX_QUEUE_SIZE", () => {
206
+ const p = makePlayer();
207
+ // Cria MAX+10 tracks
208
+ const many = Array.from({ length: Defaults.MAX_QUEUE_SIZE + 10 }, (_, i) => makeTrack(String(i)));
209
+ p.addToQueue(many);
210
+ assert.ok(p.queue.length <= Defaults.MAX_QUEUE_SIZE, `fila não deve exceder ${Defaults.MAX_QUEUE_SIZE}`);
211
+ });
212
+
213
+ test("toJSON retorna estrutura correta", () => {
214
+ const p = makePlayer("999");
215
+ const json = p.toJSON();
216
+ assert.strictEqual(json.guildId, "999");
217
+ assert.ok("state" in json);
218
+ assert.ok("current" in json);
219
+ assert.ok("queue" in json);
220
+ assert.ok("volume" in json);
221
+ assert.ok("paused" in json);
222
+ assert.ok("repeat" in json);
223
+ });
224
+
225
+ test("destroy não crasha", () => {
226
+ const p = makePlayer();
227
+ assert.doesNotThrow(() => p.destroy());
228
+ });
229
+
230
+ test("stop emite playerStop e reseta estado", () => {
231
+ const p = makePlayer();
232
+ let emitted = false;
233
+ p.on("playerStop", () => { emitted = true; });
234
+ p.stop();
235
+ assert.ok(emitted);
236
+ assert.strictEqual(p.state, PlayerState.IDLE);
237
+ assert.strictEqual(p.current, null);
238
+ });
239
+
240
+ // ── AudioServer — estrutura ───────────────────────────────────────────────────
241
+
242
+ console.log("\n── AudioServer (estrutura) ───────────────────────────────────────────────────");
243
+
244
+ test("AudioServer é exportado corretamente", () => {
245
+ const { AudioServer } = require("./src/index");
246
+ assert.ok(typeof AudioServer === "function", "AudioServer deve ser uma classe/função");
247
+ });
248
+
249
+ test("AudioServer instancia com porta customizada", () => {
250
+ const { AudioServer } = require("./src/index");
251
+ let server;
252
+ assert.doesNotThrow(() => {
253
+ server = new AudioServer({ port: 9999, password: "test" });
254
+ });
255
+ // Fecha imediatamente após criar
256
+ if (server?.wss) server.wss.close();
257
+ });
258
+
259
+ // ── Resultado ─────────────────────────────────────────────────────────────────
260
+
261
+ setTimeout(() => {
262
+ console.log(`\n── Resultado ─────────────────────────────────────────────────────────────────`);
263
+ console.log(` ✅ ${passed} passed`);
264
+ if (failed > 0) {
265
+ console.error(` ❌ ${failed} failed`);
266
+ process.exit(1);
267
+ }
268
+ console.log(" All tests passed.\n");
269
+ }, 500);