enerthya.dev-audio-core 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 +21 -0
- package/src/constants/index.js +36 -0
- package/src/decoder/AudioDecoder.js +191 -0
- package/src/index.js +157 -0
- package/src/sources/soundcloud/index.js +145 -0
- package/src/sources/youtube/index.js +282 -0
- package/src/utils/http.js +141 -0
- package/src/utils/index.js +71 -0
- package/test.js +283 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "enerthya.dev-audio-core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Enerthya Audio Core \u2014 Audio processing engine built from scratch. Resolves and streams audio from YouTube, SoundCloud and direct URLs using native HTTP requests and FFmpeg. No play-dl, no ytdl-core.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"enerthya",
|
|
12
|
+
"audio",
|
|
13
|
+
"youtube",
|
|
14
|
+
"soundcloud",
|
|
15
|
+
"ffmpeg"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node test.js"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* constants/index.js — Constantes do enerthya.dev-audio-core
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const Source = {
|
|
6
|
+
YOUTUBE: "youtube",
|
|
7
|
+
SOUNDCLOUD: "soundcloud",
|
|
8
|
+
DIRECT: "direct",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const LoadType = {
|
|
12
|
+
TRACK: "track",
|
|
13
|
+
PLAYLIST: "playlist",
|
|
14
|
+
SEARCH: "search",
|
|
15
|
+
EMPTY: "empty",
|
|
16
|
+
ERROR: "error",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const StreamType = {
|
|
20
|
+
OPUS: "opus",
|
|
21
|
+
OGG_OPUS: "ogg/opus",
|
|
22
|
+
WEBM_OPUS: "webm/opus",
|
|
23
|
+
MP3: "mp3",
|
|
24
|
+
AAC: "aac",
|
|
25
|
+
ARBITRARY: "arbitrary",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const Limits = {
|
|
29
|
+
MAX_SEARCH_RESULTS: 10,
|
|
30
|
+
MAX_PLAYLIST_TRACKS: 500,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// User-Agent moderno para não ser bloqueado pelas APIs
|
|
34
|
+
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
35
|
+
|
|
36
|
+
module.exports = { Source, LoadType, StreamType, Limits, USER_AGENT };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decoder/AudioDecoder.js — Pipeline de decodificação de áudio via FFmpeg
|
|
3
|
+
*
|
|
4
|
+
* Equivalente ao AudioTrackDecoder do lavaplayer.
|
|
5
|
+
* Usa FFmpeg (do sistema ou ffmpeg-static) para:
|
|
6
|
+
* - Decodificar qualquer formato de entrada (mp3, aac, opus, webm, m3u8)
|
|
7
|
+
* - Transcodificar para PCM s16le (formato aceito pelo @discordjs/voice)
|
|
8
|
+
* - Suportar seek por posição (ms)
|
|
9
|
+
* - Controlar volume via filtro de áudio do FFmpeg
|
|
10
|
+
*
|
|
11
|
+
* O stream PCM resultante é enviado pelo enerthya.dev-audio-server
|
|
12
|
+
* para o bot via WebSocket, que usa @discordjs/voice para tocar.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawn } = require("child_process");
|
|
16
|
+
const { EventEmitter } = require("events");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
|
|
19
|
+
// Tenta usar ffmpeg-static se disponível, senão usa o do PATH
|
|
20
|
+
function getFfmpegPath() {
|
|
21
|
+
try {
|
|
22
|
+
return require("ffmpeg-static");
|
|
23
|
+
} catch {
|
|
24
|
+
return "ffmpeg"; // assume que está no PATH
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FFMPEG_PATH = getFfmpegPath();
|
|
29
|
+
|
|
30
|
+
// ── Formatos de saída suportados ──────────────────────────────────────────────
|
|
31
|
+
const OutputFormat = {
|
|
32
|
+
PCM: "pcm", // s16le — para @discordjs/voice
|
|
33
|
+
OPUS: "opus", // opus raw — mais eficiente, mas requer Opus encoder
|
|
34
|
+
MP3: "mp3", // mp3 — compatível com qualquer player
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cria um processo FFmpeg que transcodifica um stream de entrada para PCM.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {NodeJS.ReadableStream|string} opts.input — stream ou URL de entrada
|
|
42
|
+
* @param {number} [opts.seek] — posição inicial em ms
|
|
43
|
+
* @param {number} [opts.volume] — volume 0–1000 (100 = normal)
|
|
44
|
+
* @param {string} [opts.format] — formato de saída (padrão: pcm)
|
|
45
|
+
* @returns {{ process: ChildProcess, stream: NodeJS.ReadableStream }}
|
|
46
|
+
*/
|
|
47
|
+
function createDecoder(opts = {}) {
|
|
48
|
+
const {
|
|
49
|
+
input,
|
|
50
|
+
seek = 0,
|
|
51
|
+
volume = 100,
|
|
52
|
+
format = OutputFormat.PCM,
|
|
53
|
+
} = opts;
|
|
54
|
+
|
|
55
|
+
const args = [];
|
|
56
|
+
|
|
57
|
+
// Seek antes do input (mais rápido — evita decodificar frames desnecessários)
|
|
58
|
+
if (seek > 0) {
|
|
59
|
+
args.push("-ss", String(seek / 1000));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Input
|
|
63
|
+
if (typeof input === "string") {
|
|
64
|
+
// URL — FFmpeg baixa diretamente
|
|
65
|
+
args.push(
|
|
66
|
+
"-reconnect", "1",
|
|
67
|
+
"-reconnect_streamed","1",
|
|
68
|
+
"-reconnect_delay_max","5",
|
|
69
|
+
"-i", input
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
// Stream — pipe stdin
|
|
73
|
+
args.push("-i", "pipe:0");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Filtros de áudio
|
|
77
|
+
const filters = [];
|
|
78
|
+
if (volume !== 100) {
|
|
79
|
+
filters.push(`volume=${volume / 100}`);
|
|
80
|
+
}
|
|
81
|
+
if (filters.length) {
|
|
82
|
+
args.push("-af", filters.join(","));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Output format
|
|
86
|
+
if (format === OutputFormat.PCM) {
|
|
87
|
+
args.push(
|
|
88
|
+
"-ac", "2", // 2 canais (stereo)
|
|
89
|
+
"-ar", "48000", // 48kHz (Discord usa 48kHz)
|
|
90
|
+
"-f", "s16le", // PCM signed 16-bit little-endian
|
|
91
|
+
"-acodec", "pcm_s16le",
|
|
92
|
+
"pipe:1" // output para stdout
|
|
93
|
+
);
|
|
94
|
+
} else if (format === OutputFormat.OPUS) {
|
|
95
|
+
args.push(
|
|
96
|
+
"-ac", "2",
|
|
97
|
+
"-ar", "48000",
|
|
98
|
+
"-acodec", "libopus",
|
|
99
|
+
"-b:a", "128k",
|
|
100
|
+
"-f", "opus",
|
|
101
|
+
"pipe:1"
|
|
102
|
+
);
|
|
103
|
+
} else if (format === OutputFormat.MP3) {
|
|
104
|
+
args.push(
|
|
105
|
+
"-ac", "2",
|
|
106
|
+
"-ar", "48000",
|
|
107
|
+
"-acodec", "libmp3lame",
|
|
108
|
+
"-b:a", "192k",
|
|
109
|
+
"-f", "mp3",
|
|
110
|
+
"pipe:1"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Suprime logs desnecessários do FFmpeg
|
|
115
|
+
args.unshift("-loglevel", "error");
|
|
116
|
+
|
|
117
|
+
const proc = spawn(FFMPEG_PATH, args, {
|
|
118
|
+
stdio: typeof input === "string" ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Pipe stream de entrada se necessário
|
|
122
|
+
if (typeof input !== "string" && input?.pipe) {
|
|
123
|
+
input.pipe(proc.stdin);
|
|
124
|
+
proc.stdin.on("error", () => {}); // evita crash se FFmpeg fechar antes
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Erros do FFmpeg vão para stderr — logar mas não crashar
|
|
128
|
+
proc.stderr.on("data", (data) => {
|
|
129
|
+
const msg = data.toString();
|
|
130
|
+
if (msg.includes("Error") || msg.includes("error")) {
|
|
131
|
+
proc.emit("ffmpeg-error", msg.trim());
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return { process: proc, stream: proc.stdout };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* AudioDecoder — gerencia o ciclo de vida de um processo FFmpeg por player.
|
|
140
|
+
* Emite eventos: "data", "end", "error", "start"
|
|
141
|
+
*/
|
|
142
|
+
class AudioDecoder extends EventEmitter {
|
|
143
|
+
constructor() {
|
|
144
|
+
super();
|
|
145
|
+
this._proc = null;
|
|
146
|
+
this._active = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Inicia a decodificação de uma track.
|
|
151
|
+
* @param {NodeJS.ReadableStream|string} input
|
|
152
|
+
* @param {object} [opts]
|
|
153
|
+
*/
|
|
154
|
+
start(input, opts = {}) {
|
|
155
|
+
this.stop(); // para qualquer decoder anterior
|
|
156
|
+
|
|
157
|
+
const { process: proc, stream } = createDecoder({ input, ...opts });
|
|
158
|
+
this._proc = proc;
|
|
159
|
+
this._active = true;
|
|
160
|
+
|
|
161
|
+
stream.on("data", (chunk) => this.emit("data", chunk));
|
|
162
|
+
stream.on("end", () => { this._active = false; this.emit("end"); });
|
|
163
|
+
stream.on("error", (err) => { this._active = false; this.emit("error", err); });
|
|
164
|
+
|
|
165
|
+
proc.on("ffmpeg-error", (msg) => this.emit("error", new Error(msg)));
|
|
166
|
+
proc.on("close", (code) => {
|
|
167
|
+
this._active = false;
|
|
168
|
+
if (code !== 0 && code !== null) {
|
|
169
|
+
this.emit("error", new Error(`FFmpeg saiu com código ${code}`));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
this.emit("start");
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Para o decoder e mata o processo FFmpeg. */
|
|
178
|
+
stop() {
|
|
179
|
+
if (this._proc) {
|
|
180
|
+
this._proc.stdout?.destroy();
|
|
181
|
+
this._proc.kill("SIGKILL");
|
|
182
|
+
this._proc = null;
|
|
183
|
+
this._active = false;
|
|
184
|
+
}
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
get active() { return this._active; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { AudioDecoder, createDecoder, OutputFormat, FFMPEG_PATH };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enerthya.dev-audio-core
|
|
3
|
+
*
|
|
4
|
+
* Motor de processamento de áudio da Enerthya.
|
|
5
|
+
* Equivalente ao lavaplayer (Java) — implementado do zero em Node.js.
|
|
6
|
+
*
|
|
7
|
+
* Funcionalidades:
|
|
8
|
+
* - Resolução de tracks: YouTube (Innertube API), SoundCloud (API v2), URLs diretas
|
|
9
|
+
* - Extração de streams de áudio sem ytdl-core ou play-dl
|
|
10
|
+
* - Pipeline FFmpeg para decodificação e transcodificação
|
|
11
|
+
* - Formato de track compatível com Lavalink v4
|
|
12
|
+
* - Zero dependências externas de áudio (apenas FFmpeg do sistema)
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const { loadItem, getAudioStream, AudioDecoder } = require("enerthya.dev-audio-core");
|
|
16
|
+
*
|
|
17
|
+
* // Resolver uma track
|
|
18
|
+
* const result = await loadItem("ytsearch:lofi hip hop");
|
|
19
|
+
* console.log(result.tracks[0].info.title);
|
|
20
|
+
*
|
|
21
|
+
* // Obter stream de áudio
|
|
22
|
+
* const stream = await getAudioStream(result.tracks[0].info.uri);
|
|
23
|
+
*
|
|
24
|
+
* // Decodificar com FFmpeg
|
|
25
|
+
* const decoder = new AudioDecoder();
|
|
26
|
+
* decoder.start(stream, { volume: 100 });
|
|
27
|
+
* decoder.on("data", (chunk) => { // PCM s16le });
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const YouTube = require("./sources/youtube");
|
|
31
|
+
const SoundCloud = require("./sources/soundcloud");
|
|
32
|
+
const { AudioDecoder, createDecoder, OutputFormat } = require("./decoder/AudioDecoder");
|
|
33
|
+
const { normalizeQuery, buildTrack, decodeTrack, formatDuration } = require("./utils");
|
|
34
|
+
const { Source, LoadType, StreamType, Limits } = require("./constants");
|
|
35
|
+
|
|
36
|
+
// ── loadItem — ponto de entrada principal ─────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve uma query para tracks.
|
|
40
|
+
* Equivalente ao AudioPlayerManager.loadItem() do lavaplayer.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} query
|
|
43
|
+
* @param {object} [opts]
|
|
44
|
+
* @param {number} [opts.limit] — máx resultados para busca (padrão: 1)
|
|
45
|
+
* @param {string} [opts.requesterId]
|
|
46
|
+
* @param {string} [opts.requesterTag]
|
|
47
|
+
* @returns {Promise<{ loadType: string, tracks: object[], playlist: object|null, error: string|null }>}
|
|
48
|
+
*/
|
|
49
|
+
async function loadItem(query, opts = {}) {
|
|
50
|
+
const limit = opts.limit || 1;
|
|
51
|
+
const extra = { requesterId: opts.requesterId, requesterTag: opts.requesterTag };
|
|
52
|
+
const { isUrl, source, query: q } = normalizeQuery(query);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// ── YouTube ───────────────────────────────────────────────────────────────
|
|
56
|
+
if (source === "youtube") {
|
|
57
|
+
if (isUrl) {
|
|
58
|
+
// Playlist
|
|
59
|
+
if (q.includes("list=")) {
|
|
60
|
+
const pl = await YouTube.loadPlaylist(q, extra);
|
|
61
|
+
return { loadType: LoadType.PLAYLIST, tracks: pl.tracks, playlist: { name: pl.name, uri: q }, error: null };
|
|
62
|
+
}
|
|
63
|
+
// Vídeo único
|
|
64
|
+
const track = await YouTube.loadVideo(q, extra);
|
|
65
|
+
return { loadType: LoadType.TRACK, tracks: [track], playlist: null, error: null };
|
|
66
|
+
}
|
|
67
|
+
// Busca de texto
|
|
68
|
+
const tracks = await YouTube.search(q, limit, extra);
|
|
69
|
+
if (!tracks.length) return { loadType: LoadType.EMPTY, tracks: [], playlist: null, error: null };
|
|
70
|
+
return { loadType: LoadType.SEARCH, tracks, playlist: null, error: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── SoundCloud ────────────────────────────────────────────────────────────
|
|
74
|
+
if (source === "soundcloud") {
|
|
75
|
+
if (isUrl) {
|
|
76
|
+
// Verifica se é playlist
|
|
77
|
+
try {
|
|
78
|
+
const pl = await SoundCloud.loadPlaylist(q, extra);
|
|
79
|
+
return { loadType: LoadType.PLAYLIST, tracks: pl.tracks, playlist: { name: pl.name, uri: q }, error: null };
|
|
80
|
+
} catch {
|
|
81
|
+
// Não é playlist — tenta como track
|
|
82
|
+
}
|
|
83
|
+
const track = await SoundCloud.loadTrack(q, extra);
|
|
84
|
+
return { loadType: LoadType.TRACK, tracks: [track], playlist: null, error: null };
|
|
85
|
+
}
|
|
86
|
+
const tracks = await SoundCloud.search(q, limit, extra);
|
|
87
|
+
if (!tracks.length) return { loadType: LoadType.EMPTY, tracks: [], playlist: null, error: null };
|
|
88
|
+
return { loadType: LoadType.SEARCH, tracks, playlist: null, error: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── URL direta ────────────────────────────────────────────────────────────
|
|
92
|
+
if (source === "direct") {
|
|
93
|
+
const track = buildTrack({
|
|
94
|
+
identifier: q,
|
|
95
|
+
title: q.split("/").pop()?.split("?")[0] || "Stream",
|
|
96
|
+
author: "Desconhecido",
|
|
97
|
+
uri: q,
|
|
98
|
+
length: 0,
|
|
99
|
+
isStream: true,
|
|
100
|
+
sourceName: "direct",
|
|
101
|
+
...extra,
|
|
102
|
+
});
|
|
103
|
+
return { loadType: LoadType.TRACK, tracks: [track], playlist: null, error: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { loadType: LoadType.EMPTY, tracks: [], playlist: null, error: null };
|
|
107
|
+
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { loadType: LoadType.ERROR, tracks: [], playlist: null, error: err.message };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── getAudioStream — retorna stream de áudio de uma track ─────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Retorna o stream de áudio de uma track para uso com o AudioDecoder.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} track — objeto track (com track.info.uri e track.info.sourceName)
|
|
119
|
+
* @returns {Promise<NodeJS.ReadableStream>}
|
|
120
|
+
*/
|
|
121
|
+
async function getAudioStream(track) {
|
|
122
|
+
const { uri, sourceName } = track.info || track;
|
|
123
|
+
|
|
124
|
+
if (sourceName === "youtube") return YouTube.getAudioStream(uri);
|
|
125
|
+
if (sourceName === "soundcloud") return SoundCloud.getAudioStream(uri);
|
|
126
|
+
|
|
127
|
+
// URL direta
|
|
128
|
+
const { getStream } = require("./utils/http");
|
|
129
|
+
return getStream(uri);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
// Principal
|
|
134
|
+
loadItem,
|
|
135
|
+
getAudioStream,
|
|
136
|
+
|
|
137
|
+
// Decoder
|
|
138
|
+
AudioDecoder,
|
|
139
|
+
createDecoder,
|
|
140
|
+
OutputFormat,
|
|
141
|
+
|
|
142
|
+
// Sources individuais
|
|
143
|
+
YouTube,
|
|
144
|
+
SoundCloud,
|
|
145
|
+
|
|
146
|
+
// Utilitários
|
|
147
|
+
buildTrack,
|
|
148
|
+
decodeTrack,
|
|
149
|
+
formatDuration,
|
|
150
|
+
normalizeQuery,
|
|
151
|
+
|
|
152
|
+
// Constantes
|
|
153
|
+
Source,
|
|
154
|
+
LoadType,
|
|
155
|
+
StreamType,
|
|
156
|
+
Limits,
|
|
157
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sources/soundcloud/index.js — SoundCloud source nativo
|
|
3
|
+
*
|
|
4
|
+
* Extrai metadados e streams do SoundCloud sem bibliotecas externas.
|
|
5
|
+
*
|
|
6
|
+
* Mecanismo:
|
|
7
|
+
* 1. Extrai client_id da página principal do SoundCloud (renovado automaticamente)
|
|
8
|
+
* 2. Usa a API pública /resolve para metadados
|
|
9
|
+
* 3. Usa /tracks/:id/streams para URL de stream
|
|
10
|
+
* 4. Suporta HLS (m3u8) e stream progressivo
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { request, getJson, getStream } = require("../../utils/http");
|
|
14
|
+
const { buildTrack } = require("../../utils");
|
|
15
|
+
const { Source } = require("../../constants");
|
|
16
|
+
|
|
17
|
+
// ── Client ID (cache em memória — renovado quando expirar) ────────────────────
|
|
18
|
+
|
|
19
|
+
let _clientId = null;
|
|
20
|
+
let _clientIdExp = 0;
|
|
21
|
+
|
|
22
|
+
const CLIENT_ID_TTL = 60 * 60 * 1000; // 1 hora
|
|
23
|
+
|
|
24
|
+
async function getClientId() {
|
|
25
|
+
if (_clientId && Date.now() < _clientIdExp) return _clientId;
|
|
26
|
+
|
|
27
|
+
// Busca a página principal e extrai scripts
|
|
28
|
+
const page = await request("https://soundcloud.com/", {
|
|
29
|
+
headers: { "Accept": "text/html" },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Extrai URLs dos scripts JS
|
|
33
|
+
const scriptUrls = [];
|
|
34
|
+
const scriptRegex = /<script[^>]+src="(https:\/\/a-v2\.sndcdn\.com\/assets\/[^"]+\.js)"/g;
|
|
35
|
+
let match;
|
|
36
|
+
while ((match = scriptRegex.exec(page.body)) !== null) {
|
|
37
|
+
scriptUrls.push(match[1]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Busca client_id nos scripts (começa pelos últimos, que são mais prováveis)
|
|
41
|
+
for (const url of scriptUrls.slice(-5).reverse()) {
|
|
42
|
+
try {
|
|
43
|
+
const { body } = await request(url);
|
|
44
|
+
const m = body.match(/client_id\s*:\s*"([a-zA-Z0-9]{32})"/);
|
|
45
|
+
if (m) {
|
|
46
|
+
_clientId = m[1];
|
|
47
|
+
_clientIdExp = Date.now() + CLIENT_ID_TTL;
|
|
48
|
+
return _clientId;
|
|
49
|
+
}
|
|
50
|
+
} catch { /* tenta o próximo */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error("SoundCloud: não foi possível extrair client_id");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── API helpers ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async function scApi(path, params = {}) {
|
|
59
|
+
const clientId = await getClientId();
|
|
60
|
+
const qs = new URLSearchParams({ ...params, client_id: clientId }).toString();
|
|
61
|
+
return getJson(`https://api-v2.soundcloud.com${path}?${qs}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function resolveUrl(url) {
|
|
65
|
+
return scApi("/resolve", { url });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Formatadores ──────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function trackFromData(t, extra = {}) {
|
|
71
|
+
return buildTrack({
|
|
72
|
+
identifier: String(t.id),
|
|
73
|
+
title: t.title || "Desconhecido",
|
|
74
|
+
author: t.user?.username || "Desconhecido",
|
|
75
|
+
uri: t.permalink_url || "",
|
|
76
|
+
length: t.duration || 0,
|
|
77
|
+
isStream: false,
|
|
78
|
+
artworkUrl: t.artwork_url?.replace("-large", "-t500x500") || null,
|
|
79
|
+
sourceName: Source.SOUNDCLOUD,
|
|
80
|
+
// Armazena internamente para o decoder
|
|
81
|
+
_scTrackId: t.id,
|
|
82
|
+
...extra,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── API pública ───────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Carrega uma track do SoundCloud por URL.
|
|
90
|
+
*/
|
|
91
|
+
async function loadTrack(url, extra = {}) {
|
|
92
|
+
const data = await resolveUrl(url);
|
|
93
|
+
if (data.kind !== "track") throw new Error("URL não é uma track SoundCloud");
|
|
94
|
+
return trackFromData(data, extra);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Carrega uma playlist/set do SoundCloud.
|
|
99
|
+
*/
|
|
100
|
+
async function loadPlaylist(url, extra = {}) {
|
|
101
|
+
const data = await resolveUrl(url);
|
|
102
|
+
if (data.kind !== "playlist") throw new Error("URL não é uma playlist SoundCloud");
|
|
103
|
+
|
|
104
|
+
const tracks = (data.tracks || []).map((t) => trackFromData(t, extra));
|
|
105
|
+
return { name: data.title, uri: url, tracks };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Busca tracks no SoundCloud por texto.
|
|
110
|
+
*/
|
|
111
|
+
async function search(query, limit = 5, extra = {}) {
|
|
112
|
+
const data = await scApi("/search/tracks", { q: query, limit });
|
|
113
|
+
return (data.collection || []).slice(0, limit).map((t) => trackFromData(t, extra));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Retorna o stream de áudio de uma track SoundCloud.
|
|
118
|
+
* @param {string} uri — URL da track SoundCloud
|
|
119
|
+
* @returns {Promise<NodeJS.ReadableStream>}
|
|
120
|
+
*/
|
|
121
|
+
async function getAudioStream(uri) {
|
|
122
|
+
const clientId = await getClientId();
|
|
123
|
+
const data = await resolveUrl(uri);
|
|
124
|
+
|
|
125
|
+
if (data.kind !== "track") throw new Error("URI não é uma track SoundCloud");
|
|
126
|
+
|
|
127
|
+
// Busca as streams disponíveis
|
|
128
|
+
const streamsUrl = `https://api-v2.soundcloud.com/tracks/${data.id}/streams?client_id=${clientId}`;
|
|
129
|
+
const streams = await getJson(streamsUrl);
|
|
130
|
+
|
|
131
|
+
// Prioridade: opus_0_0 > hls > http_mp3
|
|
132
|
+
const streamUrl =
|
|
133
|
+
streams["preview/opus-64000"] ||
|
|
134
|
+
streams["hls/opus-64000"] ||
|
|
135
|
+
streams["hls/mp3-128000"] ||
|
|
136
|
+
streams["progressive/mp3-128000"];
|
|
137
|
+
|
|
138
|
+
if (!streamUrl) throw new Error("SoundCloud: nenhum stream disponível");
|
|
139
|
+
|
|
140
|
+
// Se for HLS (m3u8), retorna a URL do manifesto
|
|
141
|
+
// O decoder vai lidar com HLS via FFmpeg
|
|
142
|
+
return getStream(streamUrl);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { loadTrack, loadPlaylist, search, getAudioStream, getClientId };
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sources/youtube/index.js — YouTube source nativo
|
|
3
|
+
*
|
|
4
|
+
* Extrai metadados e URLs de stream do YouTube usando
|
|
5
|
+
* requisições HTTP diretas à API interna, sem ytdl-core ou play-dl.
|
|
6
|
+
*
|
|
7
|
+
* Mecanismo:
|
|
8
|
+
* 1. Busca via YouTube Data API v1 (innertube) — sem API key
|
|
9
|
+
* 2. Extrai playerResponse via ytInitialPlayerResponse
|
|
10
|
+
* 3. Decifra nsig (assinatura de URL) quando necessário
|
|
11
|
+
* 4. Retorna URL de stream opus/webm para uso com FFmpeg
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { getJson, getStream } = require("../../utils/http");
|
|
15
|
+
const { buildTrack } = require("../../utils");
|
|
16
|
+
const { Source } = require("../../constants");
|
|
17
|
+
|
|
18
|
+
// ── Innertube API ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const INNERTUBE_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
|
|
21
|
+
const INNERTUBE_CLIENT = {
|
|
22
|
+
clientName: "WEB",
|
|
23
|
+
clientVersion: "2.20240101.00.00",
|
|
24
|
+
hl: "pt",
|
|
25
|
+
gl: "BR",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const INNERTUBE_HEADERS = {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"X-YouTube-Client-Name": "1",
|
|
31
|
+
"X-YouTube-Client-Version": "2.20240101.00.00",
|
|
32
|
+
"Origin": "https://www.youtube.com",
|
|
33
|
+
"Referer": "https://www.youtube.com/",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Faz uma requisição POST à API Innertube do YouTube.
|
|
38
|
+
*/
|
|
39
|
+
async function innertubeRequest(endpoint, body) {
|
|
40
|
+
const url = `https://www.youtube.com/youtubei/v1/${endpoint}?key=${INNERTUBE_KEY}`;
|
|
41
|
+
const { request } = require("../../utils/http");
|
|
42
|
+
const res = await request(url, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: INNERTUBE_HEADERS,
|
|
45
|
+
body: JSON.stringify({ context: { client: INNERTUBE_CLIENT }, ...body }),
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(res.body);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error("YouTube Innertube: resposta inválida");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Extração de formato de áudio ──────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extrai a melhor URL de stream de áudio dos formatos do playerResponse.
|
|
58
|
+
* Prioriza: opus/webm → ogg_opus → mp4a → qualquer áudio
|
|
59
|
+
*
|
|
60
|
+
* @param {object[]} formats — streamingData.adaptiveFormats
|
|
61
|
+
* @returns {string|null} URL do stream
|
|
62
|
+
*/
|
|
63
|
+
function extractAudioUrl(formats) {
|
|
64
|
+
const audio = formats.filter((f) => f.mimeType?.startsWith("audio/"));
|
|
65
|
+
if (!audio.length) return null;
|
|
66
|
+
|
|
67
|
+
// Prioridade: webm/opus > ogg > mp4
|
|
68
|
+
const priority = [
|
|
69
|
+
(f) => f.mimeType?.includes("webm") && f.mimeType?.includes("opus"),
|
|
70
|
+
(f) => f.mimeType?.includes("ogg"),
|
|
71
|
+
(f) => f.mimeType?.includes("mp4"),
|
|
72
|
+
() => true,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const check of priority) {
|
|
76
|
+
const match = audio.find(check);
|
|
77
|
+
if (match) {
|
|
78
|
+
// URL direta (não cifrada)
|
|
79
|
+
if (match.url) return match.url;
|
|
80
|
+
// URL cifrada — precisa decifrar signatureCipher
|
|
81
|
+
if (match.signatureCipher) {
|
|
82
|
+
return decipherUrl(match.signatureCipher);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decifra uma signatureCipher do YouTube.
|
|
91
|
+
* Extrai url, s (assinatura) e sp (parâmetro de assinatura).
|
|
92
|
+
* NOTA: a decifragem completa do nsig requer o JS player —
|
|
93
|
+
* aqui usamos a abordagem mais simples que funciona para a maioria dos casos.
|
|
94
|
+
*/
|
|
95
|
+
function decipherUrl(signatureCipher) {
|
|
96
|
+
try {
|
|
97
|
+
const params = new URLSearchParams(signatureCipher);
|
|
98
|
+
const url = params.get("url");
|
|
99
|
+
const sig = params.get("s");
|
|
100
|
+
const sp = params.get("sp") || "sig";
|
|
101
|
+
if (!url) return null;
|
|
102
|
+
return sig ? `${url}&${sp}=${encodeURIComponent(sig)}` : url;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Extração de playerResponse ────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Busca o playerResponse de um vídeo via Innertube.
|
|
112
|
+
* @param {string} videoId
|
|
113
|
+
*/
|
|
114
|
+
async function getPlayerResponse(videoId) {
|
|
115
|
+
const data = await innertubeRequest("player", {
|
|
116
|
+
videoId,
|
|
117
|
+
playbackContext: {
|
|
118
|
+
contentPlaybackContext: { signatureTimestamp: 19950 },
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (data.playabilityStatus?.status !== "OK") {
|
|
123
|
+
throw new Error(`Vídeo indisponível: ${data.playabilityStatus?.reason || "desconhecido"}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Extração de videoId ───────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function extractVideoId(url) {
|
|
132
|
+
const patterns = [
|
|
133
|
+
/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
|
134
|
+
/(?:embed|shorts)\/([a-zA-Z0-9_-]{11})/,
|
|
135
|
+
];
|
|
136
|
+
for (const p of patterns) {
|
|
137
|
+
const m = url.match(p);
|
|
138
|
+
if (m) return m[1];
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function extractPlaylistId(url) {
|
|
144
|
+
const m = url.match(/[?&]list=([a-zA-Z0-9_-]+)/);
|
|
145
|
+
return m ? m[1] : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── API pública do source ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Carrega um vídeo único do YouTube.
|
|
152
|
+
*/
|
|
153
|
+
async function loadVideo(url, extra = {}) {
|
|
154
|
+
const videoId = extractVideoId(url);
|
|
155
|
+
if (!videoId) throw new Error(`ID de vídeo inválido: ${url}`);
|
|
156
|
+
|
|
157
|
+
const data = await getPlayerResponse(videoId);
|
|
158
|
+
const details = data.videoDetails;
|
|
159
|
+
const formats = data.streamingData?.adaptiveFormats || data.streamingData?.formats || [];
|
|
160
|
+
const audioUrl = extractAudioUrl(formats);
|
|
161
|
+
|
|
162
|
+
if (!audioUrl) throw new Error("Nenhum formato de áudio disponível");
|
|
163
|
+
|
|
164
|
+
return buildTrack({
|
|
165
|
+
identifier: videoId,
|
|
166
|
+
title: details.title,
|
|
167
|
+
author: details.author,
|
|
168
|
+
uri: `https://www.youtube.com/watch?v=${videoId}`,
|
|
169
|
+
length: parseInt(details.lengthSeconds || 0) * 1000,
|
|
170
|
+
isStream: details.isLiveContent || false,
|
|
171
|
+
artworkUrl: details.thumbnail?.thumbnails?.slice(-1)[0]?.url || null,
|
|
172
|
+
sourceName: Source.YOUTUBE,
|
|
173
|
+
// _streamUrl armazenado internamente para uso pelo decoder
|
|
174
|
+
_streamUrl: audioUrl,
|
|
175
|
+
...extra,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Carrega uma playlist do YouTube.
|
|
181
|
+
*/
|
|
182
|
+
async function loadPlaylist(url, extra = {}) {
|
|
183
|
+
const playlistId = extractPlaylistId(url);
|
|
184
|
+
if (!playlistId) throw new Error(`ID de playlist inválido: ${url}`);
|
|
185
|
+
|
|
186
|
+
const data = await innertubeRequest("browse", {
|
|
187
|
+
browseId: `VL${playlistId}`,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const header = data.header?.playlistHeaderRenderer;
|
|
191
|
+
const name = header?.title?.simpleText || "Playlist";
|
|
192
|
+
|
|
193
|
+
// Extrai vídeos da playlist
|
|
194
|
+
const items = data.contents?.twoColumnBrowseResultsRenderer
|
|
195
|
+
?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer
|
|
196
|
+
?.contents?.[0]?.itemSectionRenderer?.contents?.[0]
|
|
197
|
+
?.playlistVideoListRenderer?.contents || [];
|
|
198
|
+
|
|
199
|
+
const tracks = items
|
|
200
|
+
.filter((i) => i.playlistVideoRenderer)
|
|
201
|
+
.map((i) => {
|
|
202
|
+
const v = i.playlistVideoRenderer;
|
|
203
|
+
const id = v.videoId;
|
|
204
|
+
return buildTrack({
|
|
205
|
+
identifier: id,
|
|
206
|
+
title: v.title?.runs?.[0]?.text || "Desconhecido",
|
|
207
|
+
author: v.shortBylineText?.runs?.[0]?.text || "Desconhecido",
|
|
208
|
+
uri: `https://www.youtube.com/watch?v=${id}`,
|
|
209
|
+
length: parseInt(v.lengthSeconds || 0) * 1000,
|
|
210
|
+
artworkUrl: v.thumbnail?.thumbnails?.slice(-1)[0]?.url || null,
|
|
211
|
+
sourceName: Source.YOUTUBE,
|
|
212
|
+
...extra,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return { name, uri: url, tracks };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Busca vídeos no YouTube por texto.
|
|
221
|
+
*/
|
|
222
|
+
async function search(query, limit = 5, extra = {}) {
|
|
223
|
+
const data = await innertubeRequest("search", {
|
|
224
|
+
query,
|
|
225
|
+
params: "EgIQAQ==", // filtro: apenas vídeos
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const contents = data.contents?.twoColumnSearchResultsRenderer
|
|
229
|
+
?.primaryContents?.sectionListRenderer?.contents?.[0]
|
|
230
|
+
?.itemSectionRenderer?.contents || [];
|
|
231
|
+
|
|
232
|
+
const tracks = [];
|
|
233
|
+
for (const item of contents) {
|
|
234
|
+
if (tracks.length >= limit) break;
|
|
235
|
+
const v = item.videoRenderer;
|
|
236
|
+
if (!v?.videoId) continue;
|
|
237
|
+
|
|
238
|
+
const durationText = v.lengthText?.simpleText || "0:00";
|
|
239
|
+
const parts = durationText.split(":").map(Number).reverse();
|
|
240
|
+
const durationMs = (
|
|
241
|
+
(parts[0] || 0) +
|
|
242
|
+
(parts[1] || 0) * 60 +
|
|
243
|
+
(parts[2] || 0) * 3600
|
|
244
|
+
) * 1000;
|
|
245
|
+
|
|
246
|
+
tracks.push(buildTrack({
|
|
247
|
+
identifier: v.videoId,
|
|
248
|
+
title: v.title?.runs?.[0]?.text || "Desconhecido",
|
|
249
|
+
author: v.ownerText?.runs?.[0]?.text || "Desconhecido",
|
|
250
|
+
uri: `https://www.youtube.com/watch?v=${v.videoId}`,
|
|
251
|
+
length: durationMs,
|
|
252
|
+
artworkUrl: v.thumbnail?.thumbnails?.slice(-1)[0]?.url || null,
|
|
253
|
+
sourceName: Source.YOUTUBE,
|
|
254
|
+
...extra,
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return tracks;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Retorna o stream de áudio de um vídeo para uso com FFmpeg.
|
|
263
|
+
* @param {string} uri — URL do vídeo YouTube
|
|
264
|
+
* @returns {Promise<NodeJS.ReadableStream>}
|
|
265
|
+
*/
|
|
266
|
+
async function getAudioStream(uri) {
|
|
267
|
+
const videoId = extractVideoId(uri);
|
|
268
|
+
if (!videoId) throw new Error(`ID inválido: ${uri}`);
|
|
269
|
+
|
|
270
|
+
const data = await getPlayerResponse(videoId);
|
|
271
|
+
const formats = data.streamingData?.adaptiveFormats || data.streamingData?.formats || [];
|
|
272
|
+
const audioUrl = extractAudioUrl(formats);
|
|
273
|
+
|
|
274
|
+
if (!audioUrl) throw new Error("Nenhum formato de áudio disponível");
|
|
275
|
+
|
|
276
|
+
return getStream(audioUrl, {
|
|
277
|
+
"Referer": "https://www.youtube.com/",
|
|
278
|
+
"Origin": "https://www.youtube.com",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = { loadVideo, loadPlaylist, search, getAudioStream, extractVideoId };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/http.js — HTTP client nativo do Node.js
|
|
3
|
+
*
|
|
4
|
+
* Faz requisições HTTP/HTTPS sem nenhuma dependência externa.
|
|
5
|
+
* Suporta redirects automáticos, timeout e headers customizados.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require("https");
|
|
9
|
+
const http = require("http");
|
|
10
|
+
const { USER_AGENT } = require("../constants");
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT = 10_000; // 10s
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Faz uma requisição HTTP/HTTPS e retorna o body como string.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} url
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
* @param {object} [opts.headers]
|
|
20
|
+
* @param {number} [opts.timeout]
|
|
21
|
+
* @param {number} [opts.maxRedirects]
|
|
22
|
+
* @returns {Promise<{ body: string, status: number, headers: object }>}
|
|
23
|
+
*/
|
|
24
|
+
function request(url, opts = {}) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const {
|
|
27
|
+
headers = {},
|
|
28
|
+
timeout = DEFAULT_TIMEOUT,
|
|
29
|
+
maxRedirects = 5,
|
|
30
|
+
method = "GET",
|
|
31
|
+
body = null,
|
|
32
|
+
} = opts;
|
|
33
|
+
|
|
34
|
+
let redirectCount = 0;
|
|
35
|
+
|
|
36
|
+
function doRequest(currentUrl) {
|
|
37
|
+
const parsed = new URL(currentUrl);
|
|
38
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
39
|
+
const reqHeaders = {
|
|
40
|
+
"User-Agent": USER_AGENT,
|
|
41
|
+
"Accept": "*/*",
|
|
42
|
+
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
43
|
+
...headers,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (body) {
|
|
47
|
+
reqHeaders["Content-Type"] = "application/json";
|
|
48
|
+
reqHeaders["Content-Length"] = Buffer.byteLength(body);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const req = lib.request(
|
|
52
|
+
{
|
|
53
|
+
hostname: parsed.hostname,
|
|
54
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
55
|
+
path: parsed.pathname + parsed.search,
|
|
56
|
+
method,
|
|
57
|
+
headers: reqHeaders,
|
|
58
|
+
},
|
|
59
|
+
(res) => {
|
|
60
|
+
// Redirect
|
|
61
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
62
|
+
if (redirectCount >= maxRedirects) {
|
|
63
|
+
return reject(new Error(`Muitos redirects: ${currentUrl}`));
|
|
64
|
+
}
|
|
65
|
+
redirectCount++;
|
|
66
|
+
const nextUrl = res.headers.location.startsWith("http")
|
|
67
|
+
? res.headers.location
|
|
68
|
+
: `${parsed.protocol}//${parsed.host}${res.headers.location}`;
|
|
69
|
+
return doRequest(nextUrl);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const chunks = [];
|
|
73
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
74
|
+
res.on("end", () => {
|
|
75
|
+
resolve({
|
|
76
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
77
|
+
status: res.statusCode,
|
|
78
|
+
headers: res.headers,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
res.on("error", reject);
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
req.setTimeout(timeout, () => {
|
|
86
|
+
req.destroy(new Error(`Timeout após ${timeout}ms: ${currentUrl}`));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
req.on("error", reject);
|
|
90
|
+
if (body) req.write(body);
|
|
91
|
+
req.end();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
doRequest(url);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* GET e retorna JSON parseado.
|
|
100
|
+
*/
|
|
101
|
+
async function getJson(url, opts = {}) {
|
|
102
|
+
const res = await request(url, opts);
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(res.body);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error(`Resposta não é JSON válido: ${url}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Retorna o stream de uma URL para pipe com FFmpeg.
|
|
112
|
+
* @param {string} url
|
|
113
|
+
* @param {object} [headers]
|
|
114
|
+
* @returns {Promise<import("stream").Readable>}
|
|
115
|
+
*/
|
|
116
|
+
function getStream(url, headers = {}) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const parsed = new URL(url);
|
|
119
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
120
|
+
|
|
121
|
+
lib.get(
|
|
122
|
+
{
|
|
123
|
+
hostname: parsed.hostname,
|
|
124
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
125
|
+
path: parsed.pathname + parsed.search,
|
|
126
|
+
headers: { "User-Agent": USER_AGENT, ...headers },
|
|
127
|
+
},
|
|
128
|
+
(res) => {
|
|
129
|
+
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
130
|
+
return getStream(res.headers.location, headers).then(resolve).catch(reject);
|
|
131
|
+
}
|
|
132
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
133
|
+
return reject(new Error(`HTTP ${res.statusCode}: ${url}`));
|
|
134
|
+
}
|
|
135
|
+
resolve(res);
|
|
136
|
+
}
|
|
137
|
+
).on("error", reject);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { request, getJson, getStream };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/index.js — Utilitários gerais do enerthya.dev-audio-core
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getJson, getStream } = require("./http");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detecta source e tipo da query.
|
|
9
|
+
*/
|
|
10
|
+
function normalizeQuery(query) {
|
|
11
|
+
const q = String(query || "").trim();
|
|
12
|
+
|
|
13
|
+
if (q.startsWith("ytsearch:")) return { isUrl: false, source: "youtube", query: q.slice(9).trim() };
|
|
14
|
+
if (q.startsWith("scsearch:")) return { isUrl: false, source: "soundcloud", query: q.slice(9).trim() };
|
|
15
|
+
|
|
16
|
+
if (/^https?:\/\//i.test(q)) {
|
|
17
|
+
if (/youtu\.?be|youtube\.com/i.test(q)) return { isUrl: true, source: "youtube", query: q };
|
|
18
|
+
if (/soundcloud\.com/i.test(q)) return { isUrl: true, source: "soundcloud", query: q };
|
|
19
|
+
return { isUrl: true, source: "direct", query: q };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { isUrl: false, source: "youtube", query: q };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Constrói objeto Track padronizado — mesmo formato do Lavalink v4.
|
|
27
|
+
*/
|
|
28
|
+
function buildTrack(data) {
|
|
29
|
+
const info = {
|
|
30
|
+
identifier: data.identifier || data.uri || "",
|
|
31
|
+
isSeekable: data.isSeekable !== false,
|
|
32
|
+
author: data.author || "Desconhecido",
|
|
33
|
+
length: data.length || data.duration || 0,
|
|
34
|
+
isStream: data.isStream || false,
|
|
35
|
+
position: 0,
|
|
36
|
+
title: data.title || "Desconhecido",
|
|
37
|
+
uri: data.uri || "",
|
|
38
|
+
artworkUrl: data.artworkUrl || null,
|
|
39
|
+
sourceName: data.sourceName || data.source || "unknown",
|
|
40
|
+
requesterId: data.requesterId || null,
|
|
41
|
+
requesterTag: data.requesterTag || null,
|
|
42
|
+
};
|
|
43
|
+
const encoded = Buffer.from(JSON.stringify(info)).toString("base64");
|
|
44
|
+
return { encoded, info };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decodifica track encoded (base64 → JSON).
|
|
49
|
+
*/
|
|
50
|
+
function decodeTrack(encoded) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Formata duração em ms para string legível.
|
|
60
|
+
*/
|
|
61
|
+
function formatDuration(ms) {
|
|
62
|
+
if (!ms || ms <= 0) return "0:00";
|
|
63
|
+
const s = Math.floor(ms / 1000);
|
|
64
|
+
const h = Math.floor(s / 3600);
|
|
65
|
+
const m = Math.floor((s % 3600) / 60);
|
|
66
|
+
const sec = s % 60;
|
|
67
|
+
if (h > 0) return `${h}:${String(m).padStart(2,"0")}:${String(sec).padStart(2,"0")}`;
|
|
68
|
+
return `${m}:${String(sec).padStart(2,"0")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { normalizeQuery, buildTrack, decodeTrack, formatDuration, getJson, getStream };
|
package/test.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enerthya.dev-audio-core — test suite
|
|
3
|
+
* Run: node test.js
|
|
4
|
+
* Uses Node built-in assert only.
|
|
5
|
+
* Network tests are skipped by default (set AUDIO_CORE_NETWORK_TESTS=1 to enable).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const assert = require("assert");
|
|
9
|
+
const { EventEmitter } = require("events");
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
normalizeQuery,
|
|
13
|
+
buildTrack,
|
|
14
|
+
decodeTrack,
|
|
15
|
+
formatDuration,
|
|
16
|
+
Source,
|
|
17
|
+
LoadType,
|
|
18
|
+
StreamType,
|
|
19
|
+
Limits,
|
|
20
|
+
AudioDecoder,
|
|
21
|
+
OutputFormat,
|
|
22
|
+
} = require("./src/index");
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
|
|
27
|
+
function test(name, fn) {
|
|
28
|
+
try {
|
|
29
|
+
const result = fn();
|
|
30
|
+
if (result && typeof result.then === "function") {
|
|
31
|
+
// async — não bloqueia, reporta depois
|
|
32
|
+
result
|
|
33
|
+
.then(() => { console.log(` ✅ ${name}`); passed++; })
|
|
34
|
+
.catch((err) => { console.error(` ❌ ${name}\n ${err.message}`); failed++; });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
console.log(` ✅ ${name}`);
|
|
38
|
+
passed++;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(` ❌ ${name}\n ${err.message}`);
|
|
41
|
+
failed++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function skip(name) {
|
|
46
|
+
console.log(` ⏭ ${name} (skipped)`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const NETWORK = process.env.AUDIO_CORE_NETWORK_TESTS === "1";
|
|
50
|
+
|
|
51
|
+
// ── normalizeQuery ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
console.log("\n── normalizeQuery ────────────────────────────────────────────────────────────");
|
|
54
|
+
|
|
55
|
+
test("texto simples → youtube search", () => {
|
|
56
|
+
const r = normalizeQuery("lofi hip hop");
|
|
57
|
+
assert.strictEqual(r.isUrl, false);
|
|
58
|
+
assert.strictEqual(r.source, "youtube");
|
|
59
|
+
assert.strictEqual(r.query, "lofi hip hop");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("prefixo ytsearch:", () => {
|
|
63
|
+
const r = normalizeQuery("ytsearch:lofi");
|
|
64
|
+
assert.strictEqual(r.isUrl, false);
|
|
65
|
+
assert.strictEqual(r.source, "youtube");
|
|
66
|
+
assert.strictEqual(r.query, "lofi");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("prefixo scsearch:", () => {
|
|
70
|
+
const r = normalizeQuery("scsearch:jazz");
|
|
71
|
+
assert.strictEqual(r.isUrl, false);
|
|
72
|
+
assert.strictEqual(r.source, "soundcloud");
|
|
73
|
+
assert.strictEqual(r.query, "jazz");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("URL YouTube vídeo", () => {
|
|
77
|
+
const r = normalizeQuery("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
|
|
78
|
+
assert.strictEqual(r.isUrl, true);
|
|
79
|
+
assert.strictEqual(r.source, "youtube");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("URL YouTube curta (youtu.be)", () => {
|
|
83
|
+
const r = normalizeQuery("https://youtu.be/dQw4w9WgXcQ");
|
|
84
|
+
assert.strictEqual(r.isUrl, true);
|
|
85
|
+
assert.strictEqual(r.source, "youtube");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("URL SoundCloud", () => {
|
|
89
|
+
const r = normalizeQuery("https://soundcloud.com/artist/track");
|
|
90
|
+
assert.strictEqual(r.isUrl, true);
|
|
91
|
+
assert.strictEqual(r.source, "soundcloud");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("URL direta (.mp3)", () => {
|
|
95
|
+
const r = normalizeQuery("https://example.com/audio.mp3");
|
|
96
|
+
assert.strictEqual(r.isUrl, true);
|
|
97
|
+
assert.strictEqual(r.source, "direct");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("query vazia → youtube", () => {
|
|
101
|
+
const r = normalizeQuery("");
|
|
102
|
+
assert.strictEqual(r.source, "youtube");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("query null → youtube", () => {
|
|
106
|
+
const r = normalizeQuery(null);
|
|
107
|
+
assert.strictEqual(r.source, "youtube");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── buildTrack / decodeTrack ──────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
console.log("\n── buildTrack / decodeTrack ─────────────────────────────────────────────────");
|
|
113
|
+
|
|
114
|
+
test("buildTrack retorna encoded + info", () => {
|
|
115
|
+
const t = buildTrack({
|
|
116
|
+
title: "Test Track", author: "Test Author",
|
|
117
|
+
uri: "https://youtube.com/watch?v=abc", length: 180000,
|
|
118
|
+
sourceName: "youtube",
|
|
119
|
+
});
|
|
120
|
+
assert.ok(t.encoded, "deve ter encoded");
|
|
121
|
+
assert.ok(t.info, "deve ter info");
|
|
122
|
+
assert.strictEqual(t.info.title, "Test Track");
|
|
123
|
+
assert.strictEqual(t.info.author, "Test Author");
|
|
124
|
+
assert.strictEqual(t.info.length, 180000);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("buildTrack preenche defaults para campos ausentes", () => {
|
|
128
|
+
const t = buildTrack({});
|
|
129
|
+
assert.strictEqual(t.info.title, "Desconhecido");
|
|
130
|
+
assert.strictEqual(t.info.author, "Desconhecido");
|
|
131
|
+
assert.strictEqual(t.info.length, 0);
|
|
132
|
+
assert.strictEqual(t.info.sourceName, "unknown");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("encoded é base64 válido", () => {
|
|
136
|
+
const t = buildTrack({ title: "X", uri: "https://x.com", length: 1000 });
|
|
137
|
+
const decoded = Buffer.from(t.encoded, "base64").toString("utf8");
|
|
138
|
+
assert.doesNotThrow(() => JSON.parse(decoded));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("decodeTrack recupera info corretamente", () => {
|
|
142
|
+
const t = buildTrack({ title: "ABC", uri: "https://abc.com", length: 5000, sourceName: "youtube" });
|
|
143
|
+
const info = decodeTrack(t.encoded);
|
|
144
|
+
assert.strictEqual(info.title, "ABC");
|
|
145
|
+
assert.strictEqual(info.length, 5000);
|
|
146
|
+
assert.strictEqual(info.sourceName, "youtube");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("decodeTrack retorna null para encoded inválido", () => {
|
|
150
|
+
assert.strictEqual(decodeTrack("not-base64!!!"), null);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("requesterId e requesterTag são preservados", () => {
|
|
154
|
+
const t = buildTrack({ requesterId: "123", requesterTag: "User#0001" });
|
|
155
|
+
assert.strictEqual(t.info.requesterId, "123");
|
|
156
|
+
assert.strictEqual(t.info.requesterTag, "User#0001");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── formatDuration ────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
console.log("\n── formatDuration ────────────────────────────────────────────────────────────");
|
|
162
|
+
|
|
163
|
+
test("0ms → 0:00", () => {
|
|
164
|
+
assert.strictEqual(formatDuration(0), "0:00");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("null/undefined → 0:00", () => {
|
|
168
|
+
assert.strictEqual(formatDuration(null), "0:00");
|
|
169
|
+
assert.strictEqual(formatDuration(undefined), "0:00");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("60s → 1:00", () => {
|
|
173
|
+
assert.strictEqual(formatDuration(60_000), "1:00");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("90s → 1:30", () => {
|
|
177
|
+
assert.strictEqual(formatDuration(90_000), "1:30");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("3m45s → 3:45", () => {
|
|
181
|
+
assert.strictEqual(formatDuration(225_000), "3:45");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("1h2m30s → 1:02:30", () => {
|
|
185
|
+
assert.strictEqual(formatDuration(3_750_000), "1:02:30");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("segundos com padding → 3:05", () => {
|
|
189
|
+
assert.strictEqual(formatDuration(185_000), "3:05");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Constantes ────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
console.log("\n── Constantes ────────────────────────────────────────────────────────────────");
|
|
195
|
+
|
|
196
|
+
test("Source contém youtube, soundcloud, direct", () => {
|
|
197
|
+
assert.ok(Source.YOUTUBE);
|
|
198
|
+
assert.ok(Source.SOUNDCLOUD);
|
|
199
|
+
assert.ok(Source.DIRECT);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("LoadType contém todos os tipos", () => {
|
|
203
|
+
assert.ok(LoadType.TRACK);
|
|
204
|
+
assert.ok(LoadType.PLAYLIST);
|
|
205
|
+
assert.ok(LoadType.SEARCH);
|
|
206
|
+
assert.ok(LoadType.EMPTY);
|
|
207
|
+
assert.ok(LoadType.ERROR);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("OutputFormat contém PCM, OPUS, MP3", () => {
|
|
211
|
+
assert.ok(OutputFormat.PCM);
|
|
212
|
+
assert.ok(OutputFormat.OPUS);
|
|
213
|
+
assert.ok(OutputFormat.MP3);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("Limits.MAX_SEARCH_RESULTS é número positivo", () => {
|
|
217
|
+
assert.ok(typeof Limits.MAX_SEARCH_RESULTS === "number");
|
|
218
|
+
assert.ok(Limits.MAX_SEARCH_RESULTS > 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── AudioDecoder (estrutura) ──────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
console.log("\n── AudioDecoder ──────────────────────────────────────────────────────────────");
|
|
224
|
+
|
|
225
|
+
test("AudioDecoder é instanciável", () => {
|
|
226
|
+
const d = new AudioDecoder();
|
|
227
|
+
assert.ok(d instanceof EventEmitter);
|
|
228
|
+
assert.strictEqual(d.active, false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("AudioDecoder.stop() não crasha quando não iniciado", () => {
|
|
232
|
+
const d = new AudioDecoder();
|
|
233
|
+
assert.doesNotThrow(() => d.stop());
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("AudioDecoder é EventEmitter", () => {
|
|
237
|
+
const d = new AudioDecoder();
|
|
238
|
+
let called = false;
|
|
239
|
+
d.on("end", () => { called = true; });
|
|
240
|
+
d.emit("end");
|
|
241
|
+
assert.ok(called);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── Testes de rede (opcionais) ────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
console.log("\n── Testes de rede (AUDIO_CORE_NETWORK_TESTS=1 para ativar) ──────────────────");
|
|
247
|
+
|
|
248
|
+
if (NETWORK) {
|
|
249
|
+
const { loadItem } = require("./src/index");
|
|
250
|
+
|
|
251
|
+
test("loadItem: busca YouTube retorna tracks", async () => {
|
|
252
|
+
const result = await loadItem("ytsearch:lofi hip hop", { limit: 1 });
|
|
253
|
+
assert.ok(["track","search"].includes(result.loadType), `loadType inesperado: ${result.loadType}`);
|
|
254
|
+
assert.ok(result.tracks.length > 0, "deve ter ao menos 1 track");
|
|
255
|
+
assert.ok(result.tracks[0].info.title, "track deve ter título");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("loadItem: busca SoundCloud retorna tracks", async () => {
|
|
259
|
+
const result = await loadItem("scsearch:jazz", { limit: 1 });
|
|
260
|
+
assert.ok(["track","search","empty"].includes(result.loadType));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("loadItem: URL inválida retorna error ou empty", async () => {
|
|
264
|
+
const result = await loadItem("https://youtube.com/watch?v=INVALID_ID_XYZ");
|
|
265
|
+
assert.ok(["error","empty"].includes(result.loadType));
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
skip("loadItem: busca YouTube retorna tracks");
|
|
269
|
+
skip("loadItem: busca SoundCloud retorna tracks");
|
|
270
|
+
skip("loadItem: URL inválida retorna error ou empty");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Resultado ─────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
console.log(`\n── Resultado ─────────────────────────────────────────────────────────────────`);
|
|
277
|
+
console.log(` ✅ ${passed} passed`);
|
|
278
|
+
if (failed > 0) {
|
|
279
|
+
console.error(` ❌ ${failed} failed`);
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
console.log(" All tests passed.\n");
|
|
283
|
+
}, 500);
|