yt-zmp 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,132 @@
1
- # Temporary Holding Version
1
+ # YouTube Media Downloader (experimental)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Módulo Node.js em fase de testes para consultar vídeos do YouTube e baixar uma mídia a partir dos streams que o YouTube disponibilizar.
4
+
5
+ O download é implementado pelo próprio módulo, sem usar `yt-dlp`. O extrator tenta clientes internos do YouTube (`ANDROID`, `WEB`, `MWEB`, `IOS` e `TVHTML5`) e usa o perfil que retornar os streams. Os perfis Android e iOS usam versões e parâmetros de dispositivo compatíveis com os clientes atuais observados no código do yt-dlp.
6
+
7
+ Para áudio, o seletor prioriza `audio/mp4` (AAC, geralmente itag 140) antes de WebM/Opus, pois esse stream é mais compatível com download direto e conversão para MP3.
8
+
9
+ Se um formato retornar 403, o módulo tenta os demais streams de áudio disponíveis e repete a requisição sem `Range` antes de renovar a extração. Os cookies de sessão continuam sendo usados somente nas requisições ao YouTube.
10
+
11
+ Quando o YouTube entrega `signatureCipher`, o módulo carrega o JavaScript do player em um sandbox isolado e reutiliza a rotina de assinatura do próprio player antes de montar a URL do stream. Há também um decifrador heurístico de fallback para versões mais simples do player.
12
+
13
+ Para baixar o vídeo, combinar vídeo e áudio com FFmpeg e entregar somente o áudio final, use `video-audio`:
14
+
15
+ ```bash
16
+ node download-test.js "URL_DO_YOUTUBE" video-audio 720 mp3
17
+ ```
18
+
19
+ Esse modo cria temporariamente um MP4 com vídeo e áudio, extrai o áudio com FFmpeg e remove o arquivo temporário. Use `m4a` para preservar o áudio AAC sem recodificação ou `mp3` para converter com LAME.
20
+
21
+ ## Sessão autenticada opcional
22
+
23
+ Para vídeos que exigem uma conta autenticada, o módulo aceita um arquivo de cookies no formato Netscape. Use somente cookies da sua própria conta, mantenha o arquivo fora do repositório e nunca cole os valores em logs ou mensagens. O módulo usa os cookies e o `SAPISIDHASH` apenas nas requisições ao YouTube; eles não são enviados ao host `googlevideo.com`.
24
+
25
+ ```bash
26
+ YOUTUBE_COOKIE_FILE=/caminho/privado/youtube-cookies.txt \
27
+ node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" audio 0 mp3
28
+ ```
29
+
30
+ Pela API:
31
+
32
+ ```js
33
+ await YouTube.downloadMedia(url, {
34
+ kind: 'audio',
35
+ audioFormat: 'm4a',
36
+ cookieFile: '/caminho/privado/youtube-cookies.txt'
37
+ });
38
+ ```
39
+
40
+ O arquivo deve conter linhas Netscape com domínio, caminho, expiração, nome e valor. Cookies autenticados não garantem a superação de todos os desafios anti-bot, CAPTCHA ou exigências de verificação do YouTube; nesses casos, o módulo retorna o motivo informado pelo serviço.
41
+
42
+ ## GVS PO token
43
+
44
+ Versões recentes do YouTube podem exigir um **GVS PO token** para autorizar as URLs de mídia. O yt-zmp não tenta fabricar esse token nem usa o yt-dlp para obtê-lo. Quando você possui um token legítimo para a sessão/cliente, forneça-o por variável de ambiente; ele será acrescentado como `pot` às URLs de stream durante a extração:
45
+
46
+ ```bash
47
+ YOUTUBE_GVS_PO_TOKEN='TOKEN_BASE64URL' \
48
+ YOUTUBE_COOKIE_FILE=/caminho/privado/youtube-cookies.txt \
49
+ node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" video-audio 720 mp3
50
+ ```
51
+
52
+ Também é possível passar `poToken` diretamente em `extract`, `downloadMedia` ou `downloadAudioFromVideo`. O valor não deve ser salvo no repositório nem impresso em logs. Sem PO token, alguns formatos podem aparecer na extração, mas retornar HTTP 403 no host `googlevideo.com`; nesse caso trocar cabeçalhos ou repetir a mesma URL não resolve a autorização ausente.
53
+
54
+ Para testar a sessão sem baixar mídia:
55
+
56
+ ```bash
57
+ YOUTUBE_COOKIE_FILE=/caminho/privado/youtube-cookies.txt \
58
+ npm run auth:test -- "https://www.youtube.com/watch?v=VIDEO_ID"
59
+ ```
60
+
61
+ O teste informa apenas se o arquivo foi lido, quantos cookies foram encontrados, se o `SAPISIDHASH` foi gerado e se o YouTube retornou metadados/formatos. Ele nunca imprime os valores dos cookies.
62
+
63
+ ## Uso rápido
64
+
65
+ ```bash
66
+ npm run download:test -- "https://www.youtube.com/watch?v=VIDEO_ID" muxed 720
67
+ ```
68
+
69
+ O terceiro argumento é opcional. Tipos disponíveis:
70
+
71
+ - `muxed`: vídeo com áudio no mesmo stream, quando disponível;
72
+ - `audio`: melhor stream de áudio disponível;
73
+ - `video`: melhor stream de vídeo disponível, que pode não conter áudio.
74
+
75
+ Para áudio, o formato padrão é `m4a`, baixado diretamente do stream AAC original. Para gerar MP3, o módulo baixa o stream de áudio e faz a conversão local com FFmpeg:
76
+
77
+ ```bash
78
+ node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" audio 0 mp3
79
+ ```
80
+
81
+ Ou pela API:
82
+
83
+ ```js
84
+ await YouTube.downloadMedia(url, {
85
+ kind: 'audio',
86
+ audioFormat: 'mp3',
87
+ mp3Quality: '2'
88
+ });
89
+ ```
90
+
91
+ O formato `m4a` não exige FFmpeg. O formato `mp3` exige que o executável `ffmpeg` esteja instalado e disponível no PATH; também é possível informar `ffmpegPath` na API.
92
+
93
+ Quando o cliente do YouTube fornecer somente um formato muxado, como o itag 18, o modo `audio` agora baixa esse arquivo e extrai o áudio com FFmpeg em vez de falhar por ausência de áudio adaptativo. O mesmo fallback é usado por `video-audio`. Na linha de comando, a altura pode ser informada como `720` ou `720p`.
94
+
95
+ ## Vídeo em qualidade maior
96
+
97
+ Streams muxados (`muxed`) costumam ficar limitados a 360p. Para obter uma resolução maior, use `kind: 'video'`: o módulo baixa o stream de vídeo adaptativo e o melhor áudio separadamente e combina os dois localmente com FFmpeg.
98
+
99
+ ```bash
100
+ node download-test.js "https://www.youtube.com/watch?v=VIDEO_ID" video 1080
101
+ ```
102
+
103
+ Pela API:
104
+
105
+ ```js
106
+ await YouTube.downloadMedia(url, {
107
+ kind: 'video',
108
+ height: 1080,
109
+ mergeAudio: true
110
+ });
111
+ ```
112
+
113
+ Se a resolução solicitada não existir, o módulo escolhe a melhor resolução disponível abaixo do limite informado. A combinação de vídeo de alta resolução exige FFmpeg; `mergeAudio: false` permite salvar apenas o stream de vídeo.
114
+
115
+ Os arquivos são salvos em `./downloads` por padrão. Também é possível usar a API:
116
+
117
+ ```js
118
+ import YouTube from './index.js';
119
+
120
+ const result = await YouTube.downloadMedia(url, {
121
+ kind: 'audio',
122
+ outputDir: './downloads'
123
+ });
124
+ ```
125
+
126
+ ## Status experimental
127
+
128
+ Este projeto ainda está em testes. As URLs de stream são temporárias, os formatos disponíveis variam por vídeo e o YouTube pode alterar seus endpoints ou exigir novos parâmetros. O módulo combina streams adaptativos de vídeo e áudio e converte áudio para MP3 localmente com FFmpeg. Use somente em conteúdos que você tem autorização para baixar e respeite os termos aplicáveis.
129
+
130
+ ## Erro 403 em `googlevideo.com`
131
+
132
+ Não reutilize nem salve a URL `googlevideo.com` retornada pelo YouTube. Ela é assinada, expira rapidamente e pode ficar vinculada ao IP que fez a extração. O fluxo `downloadMedia()` extrai uma URL nova a cada tentativa e baixa imediatamente, mas ainda pode falhar se a extração e o download forem executados em máquinas ou redes diferentes. Em caso de 403 persistente, verifique primeiro se o cliente exige GVS PO token, se a extração e o download ocorrem na mesma máquina/rede e se a URL ainda está dentro de `expiresInSeconds`. O downloader valida respostas parciais e tenta formatos alternativos, mas não consegue contornar uma política de autorização do servidor sem o token necessário. O projeto continua independente do yt-dlp; o código dele foi usado apenas como referência técnica.
package/auth.js ADDED
@@ -0,0 +1,63 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+
4
+ function parseNetscapeCookies(filePath) {
5
+ const text = fs.readFileSync(filePath, 'utf8');
6
+ const now = Math.floor(Date.now() / 1000);
7
+ return text
8
+ .split(/\r?\n/)
9
+ .filter(line => line && (!line.startsWith('#') || line.startsWith('#HttpOnly_')))
10
+ .map(line => line.startsWith('#HttpOnly_') ? line.slice('#HttpOnly_'.length) : line)
11
+ .map(line => line.split('\t'))
12
+ .filter(parts => parts.length >= 7)
13
+ .filter(parts => !parts[4] || Number(parts[4]) === 0 || Number(parts[4]) > now)
14
+ .map(parts => ({
15
+ domain: parts[0].toLowerCase(),
16
+ path: parts[2] || '/',
17
+ secure: parts[3] === 'TRUE',
18
+ name: parts[5],
19
+ value: parts.slice(6).join('\t')
20
+ }));
21
+ }
22
+
23
+ function cookieHeader(cookies, hostname) {
24
+ const host = hostname.toLowerCase();
25
+ const selected = cookies.filter(cookie => {
26
+ const domain = cookie.domain
27
+ .replace(/^#HttpOnly_/, '')
28
+ .replace(/^\./, '');
29
+ return host === domain || host.endsWith(`.${domain}`);
30
+ });
31
+ return selected.map(cookie => `${cookie.name}=${cookie.value}`).join('; ');
32
+ }
33
+
34
+ export function loadYouTubeAuth(cookieFile, options = {}) {
35
+ if (!cookieFile) return null;
36
+ const cookies = parseNetscapeCookies(cookieFile);
37
+ const cookie = cookieHeader(cookies, 'youtube.com');
38
+ if (!cookie) {
39
+ throw new Error('O arquivo de cookies não contém cookies do YouTube.');
40
+ }
41
+
42
+ const sapisidCookie = cookies.find(item =>
43
+ ['SAPISID', '__Secure-3PAPISID', '__Secure-1PAPISID'].includes(item.name)
44
+ );
45
+ const headers = {
46
+ Cookie: cookie
47
+ };
48
+
49
+ if (sapisidCookie && options.sapisid !== false) {
50
+ const origin = options.origin || 'https://www.youtube.com';
51
+ const timestamp = Math.floor(Date.now() / 1000);
52
+ const digest = crypto
53
+ .createHash('sha1')
54
+ .update(`${timestamp} ${sapisidCookie.value} ${origin}`)
55
+ .digest('hex');
56
+ headers.Authorization = `SAPISIDHASH ${timestamp}_${digest}`;
57
+ }
58
+
59
+ return {
60
+ headers,
61
+ cookieCount: cookies.length
62
+ };
63
+ }
package/cipher.js ADDED
@@ -0,0 +1,172 @@
1
+ import vm from 'node:vm';
2
+
3
+ function decodeEscapes(value) {
4
+ return value
5
+ .replace(/\\u0026/g, '&')
6
+ .replace(/\\u003d/g, '=')
7
+ .replace(/\\u0027/g, "'")
8
+ .replace(/\\u003c/g, '<')
9
+ .replace(/\\u003e/g, '>')
10
+ .replace(/\\u002f/g, '/');
11
+ }
12
+
13
+ function createPlayerContext() {
14
+ const context = {
15
+ console,
16
+ setTimeout,
17
+ clearTimeout,
18
+ setInterval,
19
+ clearInterval,
20
+ URL,
21
+ URLSearchParams,
22
+ TextDecoder,
23
+ TextEncoder
24
+ };
25
+ context.window = context;
26
+ context.self = context;
27
+ context.globalThis = context;
28
+ context.navigator = {
29
+ userAgent: 'Mozilla/5.0',
30
+ language: 'pt-BR',
31
+ languages: ['pt-BR']
32
+ };
33
+ context.location = {
34
+ href: 'https://m.youtube.com/',
35
+ origin: 'https://m.youtube.com',
36
+ host: 'm.youtube.com',
37
+ hostname: 'm.youtube.com',
38
+ protocol: 'https:'
39
+ };
40
+ context.document = {
41
+ cookie: '',
42
+ createElement() {
43
+ return {
44
+ style: {},
45
+ setAttribute() {},
46
+ appendChild() {}
47
+ };
48
+ },
49
+ documentElement: {},
50
+ body: {},
51
+ head: {},
52
+ addEventListener() {},
53
+ removeEventListener() {}
54
+ };
55
+ context.XMLHttpRequest = class {
56
+ open() {}
57
+ send() {}
58
+ setRequestHeader() {}
59
+ addEventListener() {}
60
+ };
61
+ return context;
62
+ }
63
+
64
+ function createRuntimeTools(source) {
65
+ const signatureCall = source.match(
66
+ /if\([^\n]{0,80}\.s\)[\s\S]{0,500}?([\w$]+)\((\d+),(\d+),([\w$]+)\((\d+),(\d+),[^)]*\.s\)\)/
67
+ );
68
+ const outer = signatureCall?.[1] || 'ca';
69
+ const outerA = signatureCall?.[2] || '15';
70
+ const outerB = signatureCall?.[3] || '5768';
71
+ const inner = signatureCall?.[4] || 'ic';
72
+ const innerA = signatureCall?.[5] || '3';
73
+ const innerB = signatureCall?.[6] || '3124';
74
+ const marker = `g.__ytzmpDecipher=function(s){return ${outer}(${outerA},${outerB},${inner}(${innerA},${innerB},s));};g.__ytzmpTransform=function(s){try{var u=new g.hc(s,!0),n=u.get("n");if(n){var v=new URL(s);v.searchParams.set("n",n);return v.toString();}}catch(e){}return s;};`;
75
+ const injected = source.replace(
76
+ /\}\)\(_yt_player\);\s*$/,
77
+ `${marker}})(_yt_player);`
78
+ );
79
+ if (injected === source) return null;
80
+
81
+ const context = createPlayerContext();
82
+ try {
83
+ vm.runInNewContext(injected, context, {
84
+ timeout: 15000,
85
+ codeGeneration: { strings: false, wasm: false }
86
+ });
87
+ const decipher = context._yt_player?.__ytzmpDecipher;
88
+ const transform = context._yt_player?.__ytzmpTransform;
89
+ if (typeof decipher !== 'function') return null;
90
+ return {
91
+ decipher: value => {
92
+ try {
93
+ return String(decipher(String(value)));
94
+ } catch {
95
+ return null;
96
+ }
97
+ },
98
+ transform: value => {
99
+ try {
100
+ return typeof transform === 'function'
101
+ ? String(transform(String(value)))
102
+ : value;
103
+ } catch {
104
+ return value;
105
+ }
106
+ }
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function applyOperation(value, operation, amount = 0) {
114
+ if (operation === 'reverse') value.reverse();
115
+ if (operation === 'splice') value.splice(0, amount);
116
+ if (operation === 'slice') value = value.slice(amount);
117
+ if (operation === 'swap') {
118
+ const index = amount % value.length;
119
+ [value[0], value[index]] = [value[index], value[0]];
120
+ }
121
+ return value;
122
+ }
123
+
124
+ function createHeuristicDecipher(source) {
125
+ const functionPattern = /(?:function\s+([\w$]+)|([\w$]+)\s*=\s*function)\s*\(([\w$]+)\)\s*\{([\s\S]{0,3000}?)\}/g;
126
+ let match;
127
+ while ((match = functionPattern.exec(source))) {
128
+ const argument = match[3];
129
+ const body = match[4];
130
+ if (!body.includes('.split(\"\")') || !body.includes('.join(\"\")')) continue;
131
+ const operations = [];
132
+ const statements = body.split(';').map(item => item.trim()).filter(Boolean);
133
+ for (const statement of statements) {
134
+ if (statement.includes(`${argument}.reverse()`)) operations.push(['reverse', 0]);
135
+ let operation = statement.match(new RegExp(`\\b${argument}\\.splice\\(0,(\\d+)\\)`));
136
+ if (operation) operations.push(['splice', Number(operation[1])]);
137
+ operation = statement.match(new RegExp(`\\b${argument}\\s*=\\s*${argument}\\.slice\\((\\d+)\\)`));
138
+ if (operation) operations.push(['slice', Number(operation[1])]);
139
+ }
140
+ if (!operations.length) continue;
141
+ return signature => {
142
+ let value = String(signature).split('');
143
+ for (const [operation, amount] of operations) value = applyOperation(value, operation, amount);
144
+ return value.join('');
145
+ };
146
+ }
147
+ return null;
148
+ }
149
+
150
+ export function createDecipher(playerJavaScript) {
151
+ return createRuntimeTools(playerJavaScript)?.decipher || createHeuristicDecipher(playerJavaScript);
152
+ }
153
+
154
+ export function createPlayerTools(playerJavaScript) {
155
+ return createRuntimeTools(playerJavaScript);
156
+ }
157
+
158
+ export function extractPlayerJavaScriptUrl(html) {
159
+ const matches = [
160
+ html.match(/"PLAYER_JS_URL":"([^"]+)"/),
161
+ html.match(/"jsUrl":"([^"]+)"/),
162
+ html.match(/\/s\/player\/[^"']+\/base\.js/)
163
+ ];
164
+ for (const match of matches) {
165
+ if (!match?.[1] && !match?.[0]) continue;
166
+ const raw = match[1] || match[0];
167
+ const decoded = decodeEscapes(raw);
168
+ if (decoded.startsWith('http')) return decoded;
169
+ if (decoded.startsWith('/')) return `https://www.youtube.com${decoded}`;
170
+ }
171
+ return null;
172
+ }
package/client.js ADDED
@@ -0,0 +1,83 @@
1
+ const DEFAULT_HEADERS = {
2
+ 'User-Agent':
3
+ 'Mozilla/5.0 (Linux; Android 13; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36',
4
+
5
+ 'Accept-Language':
6
+ 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
7
+
8
+ 'Accept':
9
+ '*/*'
10
+ };
11
+
12
+ export async function request(
13
+ url,
14
+ options = {}
15
+ ) {
16
+ const headers = {
17
+ ...DEFAULT_HEADERS,
18
+ ...(options.headers || {})
19
+ };
20
+
21
+ const response =
22
+ await fetch(url, {
23
+ ...options,
24
+
25
+ headers,
26
+
27
+ redirect: 'follow'
28
+ });
29
+
30
+ if (!response.ok) {
31
+ let body = '';
32
+
33
+ try {
34
+ body =
35
+ await response.text();
36
+ } catch {}
37
+
38
+ const retryAfter = response.headers.get('retry-after');
39
+ const suffix = response.status === 429
40
+ ? `; o YouTube limitou temporariamente esta rede${retryAfter ? `; Retry-After: ${retryAfter}s` : ''}`
41
+ : '';
42
+ const error =
43
+ new Error(
44
+ `HTTP ${response.status}: ${response.statusText}${suffix}`
45
+ );
46
+
47
+ error.status =
48
+ response.status;
49
+
50
+ error.body =
51
+ body.slice(0, 1000);
52
+
53
+ throw error;
54
+ }
55
+
56
+ return response;
57
+ }
58
+
59
+ export async function getText(
60
+ url,
61
+ options = {}
62
+ ) {
63
+ const response =
64
+ await request(
65
+ url,
66
+ options
67
+ );
68
+
69
+ return response.text();
70
+ }
71
+
72
+ export async function getJson(
73
+ url,
74
+ options = {}
75
+ ) {
76
+ const response =
77
+ await request(
78
+ url,
79
+ options
80
+ );
81
+
82
+ return response.json();
83
+ }
package/cookie-test.js ADDED
@@ -0,0 +1,36 @@
1
+ import YouTube, { loadYouTubeAuth } from './index.js';
2
+
3
+ const cookieFile = process.env.YOUTUBE_COOKIE_FILE;
4
+ const url = process.argv[2];
5
+
6
+ if (!cookieFile) {
7
+ console.error('Defina YOUTUBE_COOKIE_FILE apontando para o arquivo Netscape.');
8
+ process.exit(1);
9
+ }
10
+
11
+ try {
12
+ const auth = loadYouTubeAuth(cookieFile);
13
+ console.log('Arquivo de cookies: OK');
14
+ console.log('Cookies lidos:', auth.cookieCount);
15
+ console.log('SAPISIDHASH gerado:', Boolean(auth.headers.Authorization));
16
+
17
+ if (!url) {
18
+ console.log('Sessão não testada contra o YouTube: informe uma URL para continuar.');
19
+ process.exit(0);
20
+ }
21
+
22
+ console.log('\nConsultando o YouTube com a sessão...');
23
+ const data = await YouTube.extract(url, {
24
+ headers: auth.headers
25
+ });
26
+
27
+ console.log('Sessão aceita para consulta: SIM');
28
+ console.log('Título:', data.title || '(não informado)');
29
+ console.log('Cliente:', data.client?.name || '(não informado)');
30
+ console.log('Formatos encontrados:', data.formats.length);
31
+ console.log('Áudios encontrados:', data.formats.filter(item => item.mimeType?.startsWith('audio/')).length);
32
+ } catch (error) {
33
+ console.error('\nFalha no teste:', error.message);
34
+ console.error('Nenhum valor de cookie foi exibido.');
35
+ process.exit(1);
36
+ }
package/direct-test.js ADDED
@@ -0,0 +1,73 @@
1
+ const url = process.argv[2];
2
+
3
+ if (!url) {
4
+ console.log(
5
+ 'Uso: node direct-test.js "URL_DO_STREAM"'
6
+ );
7
+
8
+ process.exit(1);
9
+ }
10
+
11
+ try {
12
+ console.log('Testando URL diretamente...\n');
13
+
14
+ const response = await fetch(url, {
15
+ method: 'GET',
16
+ redirect: 'follow',
17
+ headers: {
18
+ 'User-Agent':
19
+ 'Mozilla/5.0 (Linux; Android 13; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36',
20
+
21
+ 'Accept':
22
+ '*/*'
23
+ }
24
+ });
25
+
26
+ console.log(
27
+ 'Status:',
28
+ response.status,
29
+ response.statusText
30
+ );
31
+
32
+ console.log(
33
+ 'Content-Type:',
34
+ response.headers.get('content-type')
35
+ );
36
+
37
+ console.log(
38
+ 'Content-Length:',
39
+ response.headers.get('content-length')
40
+ );
41
+
42
+ console.log(
43
+ 'Location:',
44
+ response.headers.get('location')
45
+ );
46
+
47
+ if (!response.ok) {
48
+ const text =
49
+ await response.text();
50
+
51
+ console.log(
52
+ '\nResposta do servidor:\n'
53
+ );
54
+
55
+ console.log(
56
+ text.slice(0, 1000)
57
+ );
58
+
59
+ process.exit(1);
60
+ }
61
+
62
+ console.log(
63
+ '\nA URL foi aceita.'
64
+ );
65
+
66
+ } catch (error) {
67
+ console.error(
68
+ '\nERRO:',
69
+ error.message
70
+ );
71
+
72
+ process.exit(1);
73
+ }
@@ -0,0 +1,61 @@
1
+ import YouTube from './index.js';
2
+
3
+ const url = process.argv[2];
4
+ const kind = process.argv[3] || 'muxed';
5
+ const height = process.argv[4]
6
+ ? Number(String(process.argv[4]).replace(/p$/i, ''))
7
+ : undefined;
8
+ const audioFormat = process.argv[5] || 'm4a';
9
+
10
+ if (!url) {
11
+ console.log('Uso: node download-test.js "URL_DO_YOUTUBE" [audio|video|video-audio|muxed] [altura] [m4a|mp3]');
12
+ process.exit(1);
13
+ }
14
+
15
+ if (!['audio', 'video', 'video-audio', 'muxed'].includes(kind)) {
16
+ console.error('Tipo inválido. Use audio, video, video-audio ou muxed.');
17
+ process.exit(1);
18
+ }
19
+
20
+ if (!['m4a', 'mp3'].includes(audioFormat)) {
21
+ console.error('Formato de áudio inválido. Use m4a ou mp3.');
22
+ process.exit(1);
23
+ }
24
+
25
+ if (height !== undefined && !Number.isFinite(height)) {
26
+ console.error('Altura inválida. Use, por exemplo, 360, 720 ou 720p.');
27
+ process.exit(1);
28
+ }
29
+
30
+ try {
31
+ console.log('\n[EXPERIMENTAL] Consultando e baixando mídia do YouTube...\n');
32
+
33
+ const downloadOptions = {
34
+ height,
35
+ audioFormat,
36
+ outputDir: './downloads',
37
+ cookieFile: process.env.YOUTUBE_COOKIE_FILE,
38
+ poToken: process.env.YOUTUBE_GVS_PO_TOKEN
39
+ };
40
+ const result = kind === 'video-audio'
41
+ ? await YouTube.downloadAudioFromVideo(url, downloadOptions)
42
+ : await YouTube.downloadMedia(url, { ...downloadOptions, kind });
43
+
44
+ console.log('\n=== DOWNLOAD OK ===');
45
+ console.log('Título:', result.title);
46
+ console.log('Tipo:', result.kind);
47
+ if (result.kind === 'video') console.log('Áudio combinado:', 'sim');
48
+ if (result.sourceKind === 'video') console.log('Fonte processada:', 'vídeo baixado e áudio extraído com FFmpeg');
49
+ if (result.audioFormat) console.log('Formato de áudio:', result.audioFormat);
50
+ console.log('Itag:', result.format.itag);
51
+ console.log('Formato original:', result.format.mimeType);
52
+ console.log('Qualidade:', result.format.quality || 'n/a');
53
+ console.log('Arquivo:', result.path);
54
+ console.log('Tamanho:', result.size, 'bytes');
55
+ if (result.format?.sourceClient) console.log('Cliente:', result.format.sourceClient);
56
+ console.log('PO token aplicado:', result.format?.poTokenApplied ? 'sim' : 'não');
57
+ } catch (error) {
58
+ console.error('\nERRO:', error.message);
59
+ if (process.env.DEBUG) console.error(error.stack);
60
+ process.exit(1);
61
+ }