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 ADDED
@@ -0,0 +1,5 @@
1
+ # enerthya.dev-audio-core
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,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);