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 +5 -0
- package/package.json +24 -0
- package/src/constants/index.js +61 -0
- package/src/index.js +29 -0
- package/src/queue/GuildPlayer.js +238 -0
- package/src/server/AudioServer.js +344 -0
- package/test.js +269 -0
package/README.md
ADDED
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);
|