eva-md 0.0.0-stage → 0.3.3

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.
Files changed (3) hide show
  1. package/README.md +40 -3
  2. package/eva.js +1748 -0
  3. package/package.json +23 -6
package/eva.js ADDED
@@ -0,0 +1,1748 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * EVA.md v0.3.2 — EVA Media Downloader by .maxon
4
+ * Pure Node.js Interactive TUI (Zero external npm dependencies!)
5
+ * Works just like Gemini CLI (`node eva.js` or `./eva.js`), with full mouse & keyboard support.
6
+ *
7
+ * Supported Sources (1-9):
8
+ * 01. Авто 05. Mixcloud 09. Internet Archive
9
+ * 02. YouTube / Music 06. Audiomack
10
+ * 03. Bandcamp 07. Audius
11
+ * 04. SoundCloud 08. ВКонтакте (VK)
12
+ *
13
+ * Integrated yt-dlp Flags ("Buttons" / Interactive Controls):
14
+ * Audio (Музыка):
15
+ * -x Extract audio only
16
+ * --audio-format <fmt> mp3, flac, m4a, wav
17
+ * --audio-quality <q> 0 (best), 320K, 192K, 128K
18
+ * --embed-metadata Embed artist, title, album tags
19
+ * --embed-thumbnail Embed album art into audio file
20
+ * Video (Видео):
21
+ * -f "bv*+ba/b" Download best video + best audio & merge
22
+ * --write-subs Download subtitles file
23
+ * --embed-subs Embed subtitles into media container
24
+ * --write-thumbnail Save thumbnail as a separate image file
25
+ * File Naming & Archive (Именование файлов):
26
+ * -o "<template>" Output template (e.g. "%(playlist_index)s. %(title)s.%(ext)s")
27
+ * --download-archive <file> Record downloaded track IDs in archive.txt
28
+ * Playlists (Плейлисты):
29
+ * --playlist-items <range> Download specific items (e.g. "1-5")
30
+ * --playlist-reverse Download playlist in reverse order
31
+ * Network & Bypass (Сеть и обход блокировок — во вкладке Settings):
32
+ * -4 Force IPv4 connection (fixes DNS issues)
33
+ * --proxy <url> Use HTTP/SOCKS proxy (e.g. http://127.0.0.1:8080)
34
+ * --cookies-from-browser <b> Extract cookies from browser (chrome, firefox, edge, brave, safari)
35
+ */
36
+
37
+ 'use strict';
38
+
39
+ const fs = require('fs');
40
+ const os = require('os');
41
+ const path = require('path');
42
+ const { StringDecoder } = require('string_decoder');
43
+ const { spawn, spawnSync } = require('child_process');
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Constants & Configuration
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const APP_VERSION = 'v0.3.2 (Node.js)';
50
+ const CONFIG_PATH = path.join(os.homedir(), '.config', 'eva_md', 'config.json');
51
+
52
+ // Strictly 1-cell narrow glyphs (CP437/WGL4 safe) so Windows Terminal / PowerShell NEVER shifts borders!
53
+ const SOURCES = [
54
+ { id: 1, code: '01', icon: '*', name: 'Авто', domain: '', hint: 'URL любого поддерживаемого сервиса' },
55
+ { id: 2, code: '02', icon: '►', name: 'YouTube / Music', domain: 'youtube.com', hint: 'https://www.youtube.com/watch?v=... или https://music.youtube.com/watch?v=...' },
56
+ { id: 3, code: '03', icon: '♦', name: 'Bandcamp', domain: 'bandcamp.com', hint: 'https://artist.bandcamp.com/album/...' },
57
+ { id: 4, code: '04', icon: '≈', name: 'SoundCloud', domain: 'soundcloud.com', hint: 'https://soundcloud.com/artist/track' },
58
+ { id: 5, code: '05', icon: 'ʍ', name: 'Mixcloud', domain: 'mixcloud.com', hint: 'https://www.mixcloud.com/artist/show/' },
59
+ { id: 6, code: '06', icon: '~', name: 'Audiomack', domain: 'audiomack.com', hint: 'https://audiomack.com/artist/song/...' },
60
+ { id: 7, code: '07', icon: 'Δ', name: 'Audius', domain: 'audius.co', hint: 'https://audius.co/artist/track' },
61
+ { id: 8, code: '08', icon: 'V', name: 'ВКонтакте (VK)', domain: 'vk.com', hint: 'https://vk.com/audio... or https://vkvideo.ru/...' },
62
+ { id: 9, code: '09', icon: '⌂', name: 'Internet Archive', domain: 'archive.org', hint: 'https://archive.org/details/...' },
63
+ ];
64
+
65
+ const AUDIO_FORMATS = ['mp3', 'm4a', 'flac', 'wav'];
66
+ const AUDIO_QUALITIES = [
67
+ { label: 'best', val: '0' },
68
+ { label: '320k', val: '320K' },
69
+ { label: '192k', val: '192K' },
70
+ { label: '128k', val: '128K' },
71
+ ];
72
+ const BROWSERS = ['none', 'chrome', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'safari'];
73
+
74
+ const NAMING_PRESETS = [
75
+ { label: 'Плейлист: 01. Название файла.mp4(формат)', tpl: '%(playlist_index)s. %(title)s.%(ext)s' },
76
+ { label: 'Стандарт: Название файла.mp4(формат)', tpl: '%(title)s.%(ext)s' },
77
+ { label: 'Автор - Название: Автор - Название.mp4(формат)', tpl: '%(uploader)s - %(title)s.%(ext)s' },
78
+ ];
79
+
80
+ const ASCII_LOGO = [
81
+ '███████╗██╗ ██╗ █████╗ ⠀⠨⣔⢺⢿⡿⠟⠛⠋⠩⠥⠄⠀⠌⠉⠝⠻⠿⣿⣿⣷⣿⣿⣳⡀',
82
+ '██╔════╝██║ ██║██╔══██╗ ███╗ ███╗ ██████╗⠀⠀ ⢳⣭⢯⡆⠁⠠⠀⢀⣠⣤⠀⠀⠈⠀⠀⠈⡑⢬⢻⣿⣿⣿⣿⣧⡂⠌⠀ ',
83
+ '█████╗ ╚██╗ ██╔╝███████║ ██╔████╔██╗██╔══██╗ ⠈⠲⣽⣻⣽⢦⠀⠠⠝⣿⣷⡀⠀⠶⠂⠀⣸⣿⣟⠕⢼⣿⣿⣿⢿⢦',
84
+ '██╔══╝ ╚████╔╝ ██╔══██║ ██║╚██╔╝██║██║ ██║ ⢣⣼⣻⣿⣟⢇⠀⡜⠤⠻⢿⣄⣀⣀⠼⢟⡻⣃⠀⠘⣿⣿⠟',
85
+ '███████╗ ╚██╔╝ ██║ ██║ ██╗██║ ╚═╝ ██║██████╔╝ ⠐⢮⡿⣽⣿⣿⣞⡵⢢⣌⣦⣕⣢⣬⣧⣺⣿⣿⣿⣿⠝',
86
+ '╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ⠲⠕⠻⢿⣿⡻⣿⢯⢟⡿⢯⠟⠛⠻⠝⠋',
87
+ ];
88
+
89
+ // ANSI Styling Helpers
90
+ const CSI = '\x1b[';
91
+ const ANSI = {
92
+ reset: `${CSI}0m`,
93
+ bold: `${CSI}1m`,
94
+ dim: `${CSI}2m`,
95
+ fgWhite: `${CSI}37m`,
96
+ fgCyan: `${CSI}38;2;8;128;128m`,
97
+ fgGreen: `${CSI}32m`,
98
+ fgYellow: `${CSI}33m`,
99
+ fgRed: `${CSI}31m`,
100
+ fgBlack: `${CSI}30m`,
101
+ fgGray: `${CSI}38;2;105;105;105m`,
102
+ fgOrange: `${CSI}38;2;222;91;9m`, // Текст цвета RGB(222, 91, 9)
103
+ bgOrange: `${CSI}48;2;222;91;9m`,
104
+ bgGray: `${CSI}48;2;105;105;105m`,
105
+ bgWhite: `${CSI}47m`,
106
+ bgCyan: `${CSI}46m`,
107
+ hideCursor: `${CSI}?25l`,
108
+ showCursor: `${CSI}?25h`,
109
+ altScreenOn: `${CSI}?1049h`,
110
+ altScreenOff: `${CSI}?1049l`,
111
+ mouseOn: `${CSI}?1000h${CSI}?1006h`,
112
+ mouseOff: `${CSI}?1000l${CSI}?1006l`,
113
+ clearScreen: `${CSI}2J${CSI}H`,
114
+ };
115
+
116
+ function whichBinary(binName) {
117
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
118
+ const res = spawnSync(cmd, [binName], { encoding: 'utf8' });
119
+ if (res.status === 0 && res.stdout) {
120
+ return res.stdout.trim().split(/\r?\n/)[0];
121
+ }
122
+ try {
123
+ const bundledPath = binName === 'yt-dlp'
124
+ ? require('youtube-dl-exec').constants.YOUTUBE_DL_PATH
125
+ : binName === 'ffmpeg'
126
+ ? require('ffmpeg-static')
127
+ : null;
128
+ if (typeof bundledPath === 'string' && fs.existsSync(bundledPath)) {
129
+ return bundledPath;
130
+ }
131
+ } catch (_) {}
132
+ return null;
133
+ }
134
+
135
+ function shellQuote(arg) {
136
+ if (/[\s*%"'\\]/.test(arg)) {
137
+ return `"${arg.replace(/"/g, '\\"')}"`;
138
+ }
139
+ return arg;
140
+ }
141
+
142
+ function formatBytesToMB(bytes) {
143
+ if (!bytes || bytes <= 0) return '—';
144
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
145
+ }
146
+
147
+ function wrapTextByWords(text, maxWidth) {
148
+ if (maxWidth <= 10) return [text.slice(0, Math.max(1, maxWidth))];
149
+ const words = String(text).split(' ');
150
+ const lines = [];
151
+ let cur = '';
152
+
153
+ for (const w of words) {
154
+ if (!cur) {
155
+ if (w.length <= maxWidth) {
156
+ cur = w;
157
+ } else {
158
+ let rem = w;
159
+ while (rem.length > maxWidth) {
160
+ lines.push(rem.slice(0, maxWidth));
161
+ rem = ' ' + rem.slice(maxWidth);
162
+ }
163
+ cur = rem;
164
+ }
165
+ } else if (cur.length + 1 + w.length <= maxWidth) {
166
+ cur += ' ' + w;
167
+ } else {
168
+ lines.push(cur);
169
+ const indented = ' ' + w;
170
+ if (indented.length <= maxWidth) {
171
+ cur = indented;
172
+ } else {
173
+ let rem = indented;
174
+ while (rem.length > maxWidth) {
175
+ lines.push(rem.slice(0, maxWidth));
176
+ rem = ' ' + rem.slice(maxWidth);
177
+ }
178
+ cur = rem;
179
+ }
180
+ }
181
+ }
182
+ if (cur) lines.push(cur);
183
+ return lines;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Options & Command Builder
188
+ // ---------------------------------------------------------------------------
189
+
190
+ function createDefaultOptions() {
191
+ return {
192
+ sourceId: 1,
193
+ url: '',
194
+ outputDir: path.join(os.homedir(), 'Downloads'),
195
+
196
+ // Audio (Музыка)
197
+ extractAudio: true, // -x
198
+ audioFormat: 'mp3', // --audio-format mp3|flac|m4a|wav
199
+ audioQuality: '0', // --audio-quality 0|320K|128K
200
+ embedMetadata: true, // --embed-metadata
201
+ embedThumbnail: true, // --embed-thumbnail
202
+
203
+ // Video (Видео)
204
+ bestVideoMerge: false, // -f "bv*+ba/b"
205
+ writeSubs: false, // --write-subs
206
+ embedSubs: false, // --embed-subs
207
+ writeThumbnail: false, // --write-thumbnail
208
+
209
+ // Naming & Archive (Именование файлов)
210
+ usePlaylistTemplate: true, // -o "%(playlist_index)s. %(title)s.%(ext)s"
211
+ outputTemplate: '%(playlist_index)s. %(title)s.%(ext)s',
212
+ useDownloadArchive: false, // --download-archive archive.txt
213
+ archiveFile: 'archive.txt',
214
+
215
+ // Playlists (Плейлисты)
216
+ usePlaylistItems: false, // --playlist-items 1-5
217
+ playlistItems: '1-5',
218
+ playlistReverse: false, // --playlist-reverse
219
+
220
+ // Network & Bypass (Сеть и обход блокировок — во вкладке Settings)
221
+ forceIpv4: false, // -4
222
+ useProxy: false, // --proxy http://127.0.0.1:8080
223
+ proxyUrl: 'http://127.0.0.1:8080',
224
+ useCookiesBrowser: false, // --cookies-from-browser chrome
225
+ cookiesBrowser: 'chrome',
226
+ };
227
+ }
228
+
229
+ function loadSavedOptions() {
230
+ const opts = createDefaultOptions();
231
+ try {
232
+ if (fs.existsSync(CONFIG_PATH)) {
233
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
234
+ Object.assign(opts, raw);
235
+ }
236
+ } catch (_) {}
237
+ return opts;
238
+ }
239
+
240
+ function buildYtdlpArgs(opts, targetUrl = null) {
241
+ // Force UTF-8 output from yt-dlp so Russian/Cyrillic track titles never become '?' on Windows!
242
+ const args = ['--newline', '--progress', '--encoding', 'utf-8'];
243
+
244
+ // Audio vs Video
245
+ if (opts.extractAudio && !opts.bestVideoMerge) {
246
+ args.push('-x');
247
+ args.push('--audio-format', opts.audioFormat);
248
+ args.push('--audio-quality', opts.audioQuality);
249
+ } else if (opts.bestVideoMerge) {
250
+ args.push('-f', 'bv*+ba/b');
251
+ args.push('--merge-output-format', 'mp4');
252
+ if (opts.extractAudio) {
253
+ args.push('-x');
254
+ args.push('--audio-format', opts.audioFormat);
255
+ args.push('--audio-quality', opts.audioQuality);
256
+ }
257
+ }
258
+
259
+ // Metadata, Thumbnails & Subtitles
260
+ if (opts.embedMetadata) args.push('--embed-metadata');
261
+ if (opts.embedThumbnail) args.push('--embed-thumbnail');
262
+ if (opts.writeSubs) args.push('--write-subs');
263
+ if (opts.embedSubs) args.push('--embed-subs');
264
+ if (opts.writeThumbnail) args.push('--write-thumbnail');
265
+
266
+ // File Naming & Archive
267
+ const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
268
+ const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
269
+ args.push('-o', path.join(outDir, tpl));
270
+
271
+ if (opts.useDownloadArchive && opts.archiveFile.trim()) {
272
+ args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
273
+ }
274
+
275
+ // Playlists
276
+ if (opts.usePlaylistItems && opts.playlistItems.trim()) {
277
+ args.push('--playlist-items', opts.playlistItems.trim());
278
+ }
279
+ if (opts.playlistReverse) {
280
+ args.push('--playlist-reverse');
281
+ }
282
+
283
+ // Network & Bypass
284
+ if (opts.forceIpv4) {
285
+ args.push('-4');
286
+ }
287
+ if (opts.useProxy && opts.proxyUrl.trim()) {
288
+ args.push('--proxy', opts.proxyUrl.trim());
289
+ }
290
+ if (opts.useCookiesBrowser && opts.cookiesBrowser && opts.cookiesBrowser !== 'none') {
291
+ args.push('--cookies-from-browser', opts.cookiesBrowser);
292
+ }
293
+
294
+ const ffmpegPath = whichBinary('ffmpeg');
295
+ if (ffmpegPath) {
296
+ args.push('--ffmpeg-location', path.dirname(ffmpegPath));
297
+ }
298
+
299
+ const urlToUse = (targetUrl !== null ? targetUrl : opts.url).trim();
300
+ if (urlToUse) {
301
+ args.push(urlToUse);
302
+ }
303
+ return args;
304
+ }
305
+
306
+ function buildCommandPreview(opts) {
307
+ const url = opts.url.trim() ? opts.url.trim() : '<URL>';
308
+ const args = buildYtdlpArgs(opts, url);
309
+ return 'yt-dlp ' + args.map(shellQuote).join(' ');
310
+ }
311
+
312
+ function detectSource(url, defaultId = 1) {
313
+ const low = (url || '').toLowerCase();
314
+ if (low.includes('music.youtube.com')) return { id: 2, name: 'YouTube Music' };
315
+ if (low.includes('youtube.com') || low.includes('youtu.be')) return { id: 2, name: 'YouTube' };
316
+ if (low.includes('bandcamp.com')) return { id: 3, name: 'Bandcamp' };
317
+ if (low.includes('soundcloud.com')) return { id: 4, name: 'SoundCloud' };
318
+ if (low.includes('mixcloud.com')) return { id: 5, name: 'Mixcloud' };
319
+ if (low.includes('audiomack.com')) return { id: 6, name: 'Audiomack' };
320
+ if (low.includes('audius.co')) return { id: 7, name: 'Audius' };
321
+ if (low.includes('vk.com') || low.includes('vkvideo.ru')) return { id: 8, name: 'ВКонтакте (VK)' };
322
+ if (low.includes('archive.org')) return { id: 9, name: 'Internet Archive' };
323
+ const found = SOURCES.find((s) => s.id === defaultId) || SOURCES[0];
324
+ return { id: found.id, name: found.name };
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Download Manager (Subprocess Spawner & Real-Time Progress Parser)
329
+ // ---------------------------------------------------------------------------
330
+
331
+ class DownloadManager {
332
+ constructor(simulateIfMissing = false, onUpdate = () => {}) {
333
+ this.items = [];
334
+ this.nextId = 1;
335
+ this.ytdlpPath = whichBinary('yt-dlp');
336
+ this.ffmpegPath = whichBinary('ffmpeg');
337
+ this.simulateIfMissing = simulateIfMissing;
338
+ this.onUpdate = onUpdate;
339
+
340
+ this.progressRe = /\[download\]\s+(\d+(?:\.\d+)?)%\s+of\s+~?(\S+)(?:\s+at\s+(\S+))?(?:\s+ETA\s+(\S+))?/;
341
+ this.destRe = /\[(?:download|ExtractAudio|Merger)\]\s+Destination:\s+(.+)/;
342
+ this.alreadyRe = /\[download\]\s+(.+?)\s+has already been downloaded/;
343
+ this.mergerRe = /\[Merger\]\s+Merging formats into\s+"(.+?)"/;
344
+ }
345
+
346
+ addTask(opts, startImmediately = true) {
347
+ const url = opts.url.trim();
348
+ const src = detectSource(url, opts.sourceId);
349
+ const fmt = opts.bestVideoMerge && !opts.extractAudio ? 'mp4/mkv' : opts.audioFormat;
350
+ const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
351
+ const cmdArgs = buildYtdlpArgs(opts, url);
352
+
353
+ let shortTitle = url.replace(/^https?:\/\//, '');
354
+ const item = {
355
+ id: this.nextId++,
356
+ url,
357
+ title: `[${src.name}] ${shortTitle.slice(0, 44)}`,
358
+ sourceName: src.name,
359
+ fmt,
360
+ outDir,
361
+ destFile: '',
362
+ sizeStr: '—',
363
+ progress: 0,
364
+ speedStr: '',
365
+ etaStr: '',
366
+ status: 'Queued',
367
+ cmdArgs,
368
+ errorMsg: '',
369
+ proc: null,
370
+ simTimer: null,
371
+ logLines: [],
372
+ };
373
+ this.items.push(item);
374
+ if (startImmediately) {
375
+ this.startItem(item);
376
+ }
377
+ this.onUpdate();
378
+ return item;
379
+ }
380
+
381
+ _updateFinalFileSize(item) {
382
+ if (item.destFile) {
383
+ try {
384
+ if (fs.existsSync(item.destFile)) {
385
+ const st = fs.statSync(item.destFile);
386
+ if (st.isFile() && st.size > 0) {
387
+ item.sizeStr = formatBytesToMB(st.size);
388
+ item.title = path.basename(item.destFile, path.extname(item.destFile));
389
+ return;
390
+ }
391
+ }
392
+ const dir = path.dirname(item.destFile);
393
+ const stem = path.basename(item.destFile, path.extname(item.destFile));
394
+ if (fs.existsSync(dir)) {
395
+ const candidates = fs
396
+ .readdirSync(dir)
397
+ .filter((name) => path.basename(name, path.extname(name)) === stem)
398
+ .map((name) => {
399
+ const full = path.join(dir, name);
400
+ return { name, full, stat: fs.statSync(full) };
401
+ })
402
+ .filter((f) => f.stat.isFile() && !f.name.endsWith('.part') && !f.name.endsWith('.ytdl'))
403
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
404
+ if (candidates.length > 0) {
405
+ item.destFile = candidates[0].full;
406
+ item.sizeStr = formatBytesToMB(candidates[0].stat.size);
407
+ item.title = path.basename(candidates[0].full, path.extname(candidates[0].full));
408
+ return;
409
+ }
410
+ }
411
+ } catch (_) {}
412
+ }
413
+
414
+ if (item.outDir && fs.existsSync(item.outDir)) {
415
+ try {
416
+ const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
417
+ const recent = fs
418
+ .readdirSync(item.outDir)
419
+ .map((name) => {
420
+ const full = path.join(item.outDir, name);
421
+ return { name, full, stat: fs.statSync(full), ext: path.extname(name).toLowerCase() };
422
+ })
423
+ .filter((f) => f.stat.isFile() && exts.has(f.ext) && Date.now() - f.stat.mtimeMs < 60000)
424
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
425
+ if (recent.length > 0) {
426
+ item.destFile = recent[0].full;
427
+ item.sizeStr = formatBytesToMB(recent[0].stat.size);
428
+ item.title = path.basename(recent[0].full, path.extname(recent[0].full));
429
+ }
430
+ } catch (_) {}
431
+ }
432
+ }
433
+
434
+ startItem(item) {
435
+ if (item.status === 'Downloading') return;
436
+ item.status = 'Downloading';
437
+ item.errorMsg = '';
438
+
439
+ const oIdx = item.cmdArgs.indexOf('-o');
440
+ if (oIdx !== -1 && item.cmdArgs[oIdx + 1]) {
441
+ const dir = path.dirname(item.cmdArgs[oIdx + 1]);
442
+ item.outDir = dir;
443
+ try {
444
+ fs.mkdirSync(dir, { recursive: true });
445
+ } catch (_) {}
446
+ }
447
+
448
+ if (!this.ytdlpPath) {
449
+ if (this.simulateIfMissing) {
450
+ this._simulateDownload(item);
451
+ } else {
452
+ item.status = 'Error';
453
+ item.errorMsg = 'yt-dlp not found in PATH';
454
+ item.logLines.push('Error: yt-dlp binary not found. Install via brew/apt/winget or download standalone binary.');
455
+ }
456
+ this.onUpdate();
457
+ return;
458
+ }
459
+
460
+ const childEnv = Object.assign({}, process.env, {
461
+ PYTHONIOENCODING: 'utf-8',
462
+ PYTHONUTF8: '1',
463
+ LANG: 'en_US.UTF-8',
464
+ LC_ALL: 'en_US.UTF-8',
465
+ });
466
+
467
+ const proc = spawn(this.ytdlpPath, item.cmdArgs, {
468
+ stdio: ['ignore', 'pipe', 'pipe'],
469
+ env: childEnv,
470
+ });
471
+ item.proc = proc;
472
+
473
+ const handleLine = (line) => {
474
+ const trimmed = line.trim();
475
+ if (!trimmed) return;
476
+ item.logLines.push(trimmed);
477
+ if (item.logLines.length > 200) item.logLines.shift();
478
+
479
+ const mDest = this.destRe.exec(trimmed) || this.mergerRe.exec(trimmed) || this.alreadyRe.exec(trimmed);
480
+ if (mDest) {
481
+ const destPath = mDest[1].trim().replace(/^"|"$/g, '');
482
+ item.destFile = destPath;
483
+ item.title = path.basename(destPath, path.extname(destPath));
484
+ }
485
+
486
+ const mProg = this.progressRe.exec(trimmed);
487
+ if (mProg) {
488
+ item.progress = parseFloat(mProg[1]) || item.progress;
489
+ const rawSz = mProg[2] || '';
490
+ if (rawSz) {
491
+ const mibMatch = /^(\d+(?:\.\d+)?)MiB$/i.exec(rawSz);
492
+ const kibMatch = /^(\d+(?:\.\d+)?)KiB$/i.exec(rawSz);
493
+ if (mibMatch) {
494
+ const bytes = parseFloat(mibMatch[1]) * 1024 * 1024;
495
+ item.sizeStr = formatBytesToMB(bytes);
496
+ } else if (kibMatch) {
497
+ const bytes = parseFloat(kibMatch[1]) * 1024;
498
+ item.sizeStr = formatBytesToMB(bytes);
499
+ } else {
500
+ item.sizeStr = rawSz;
501
+ }
502
+ }
503
+ item.speedStr = mProg[3] || '';
504
+ item.etaStr = mProg[4] || '';
505
+ }
506
+ this.onUpdate();
507
+ };
508
+
509
+ const stdoutDecoder = new StringDecoder('utf8');
510
+ const stderrDecoder = new StringDecoder('utf8');
511
+ let stdoutBuf = '';
512
+ let stderrBuf = '';
513
+
514
+ proc.stdout.on('data', (chunk) => {
515
+ stdoutBuf += stdoutDecoder.write(chunk);
516
+ const parts = stdoutBuf.split(/[\r\n]+/);
517
+ stdoutBuf = parts.pop();
518
+ for (const p of parts) handleLine(p);
519
+ });
520
+
521
+ proc.stderr.on('data', (chunk) => {
522
+ stderrBuf += stderrDecoder.write(chunk);
523
+ const parts = stderrBuf.split(/[\r\n]+/);
524
+ stderrBuf = parts.pop();
525
+ for (const p of parts) handleLine(p);
526
+ });
527
+
528
+ proc.on('close', (code) => {
529
+ const remOut = stdoutBuf + stdoutDecoder.end();
530
+ const remErr = stderrBuf + stderrDecoder.end();
531
+ if (remOut.trim()) handleLine(remOut);
532
+ if (remErr.trim()) handleLine(remErr);
533
+
534
+ item.proc = null;
535
+ if (item.status === 'Cancelled' || item.status === 'Paused') {
536
+ this.onUpdate();
537
+ return;
538
+ }
539
+ if (code === 0) {
540
+ item.progress = 100;
541
+ item.status = 'Done';
542
+ this._updateFinalFileSize(item);
543
+ } else {
544
+ item.status = 'Error';
545
+ item.errorMsg = item.logLines[item.logLines.length - 1] || `Exit code ${code}`;
546
+ }
547
+ this.onUpdate();
548
+ });
549
+ }
550
+
551
+ pauseItem(item) {
552
+ if (item.status !== 'Downloading') return;
553
+ if (item.proc) {
554
+ try {
555
+ item.proc.kill('SIGSTOP');
556
+ item.status = 'Paused';
557
+ } catch (_) {
558
+ item.proc.kill('SIGTERM');
559
+ item.status = 'Paused';
560
+ }
561
+ } else if (item.simTimer) {
562
+ item.status = 'Paused';
563
+ }
564
+ this.onUpdate();
565
+ }
566
+
567
+ resumeItem(item) {
568
+ if (item.status === 'Paused' && item.proc) {
569
+ try {
570
+ item.proc.kill('SIGCONT');
571
+ item.status = 'Downloading';
572
+ this.onUpdate();
573
+ return;
574
+ } catch (_) {}
575
+ }
576
+ if (item.status === 'Paused' && item.simTimer) {
577
+ item.status = 'Downloading';
578
+ this.onUpdate();
579
+ return;
580
+ }
581
+ if (['Paused', 'Queued', 'Error', 'Cancelled'].includes(item.status)) {
582
+ this.startItem(item);
583
+ }
584
+ }
585
+
586
+ cancelItem(item) {
587
+ if (item.simTimer) {
588
+ clearInterval(item.simTimer);
589
+ item.simTimer = null;
590
+ }
591
+ if (item.proc) {
592
+ try {
593
+ item.proc.kill('SIGTERM');
594
+ } catch (_) {}
595
+ item.proc = null;
596
+ }
597
+ item.status = 'Cancelled';
598
+ this.onUpdate();
599
+ }
600
+
601
+ removeItem(idx) {
602
+ if (idx >= 0 && idx < this.items.length) {
603
+ this.cancelItem(this.items[idx]);
604
+ this.items.splice(idx, 1);
605
+ this.items.forEach((it, i) => {
606
+ it.id = i + 1;
607
+ });
608
+ this.nextId = this.items.length + 1;
609
+ this.onUpdate();
610
+ }
611
+ }
612
+
613
+ _simulateDownload(item) {
614
+ item.sizeStr = '8.40 MB';
615
+ item.simTimer = setInterval(() => {
616
+ if (item.status === 'Paused') return;
617
+ if (item.status === 'Cancelled') {
618
+ clearInterval(item.simTimer);
619
+ item.simTimer = null;
620
+ return;
621
+ }
622
+ item.progress = Math.min(100, item.progress + 4);
623
+ item.speedStr = '3.5MiB/s';
624
+ item.etaStr = `00:${String(Math.max(0, Math.round((100 - item.progress) / 4))).padStart(2, '0')}`;
625
+ item.logLines.push(`[download] ${item.progress.toFixed(1)}% of ${item.sizeStr} at ${item.speedStr} ETA ${item.etaStr}`);
626
+ if (item.progress >= 100) {
627
+ clearInterval(item.simTimer);
628
+ item.simTimer = null;
629
+ item.status = 'Done';
630
+ }
631
+ this.onUpdate();
632
+ }, 120);
633
+ }
634
+ }
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // Virtual Screen Buffer & Node.js TUI Engine
638
+ // ---------------------------------------------------------------------------
639
+
640
+ class ScreenBuffer {
641
+ constructor(cols, rows) {
642
+ this.cols = cols;
643
+ this.rows = rows;
644
+ this.chars = Array.from({ length: rows }, () => Array(cols).fill(' '));
645
+ this.styles = Array.from({ length: rows }, () => Array(cols).fill(ANSI.fgWhite));
646
+ }
647
+
648
+ putStr(y, x, text, style = ANSI.fgWhite) {
649
+ if (y < 0 || y >= this.rows) return;
650
+ const str = String(text);
651
+ for (let i = 0; i < str.length; i++) {
652
+ const cx = x + i;
653
+ if (cx >= 0 && cx < this.cols - 1) {
654
+ this.chars[y][cx] = str[i];
655
+ this.styles[y][cx] = style;
656
+ }
657
+ }
658
+ }
659
+
660
+ drawBox(y, x, h, w, title = '', style = ANSI.fgWhite, titleStyle = ANSI.bold + ANSI.fgCyan) {
661
+ if (h < 2 || w < 4) return;
662
+ this.putStr(y, x, '┌' + '─'.repeat(w - 2) + '┐', style);
663
+ for (let r = y + 1; r < y + h - 1; r++) {
664
+ this.putStr(r, x, '│', style);
665
+ this.putStr(r, x + w - 1, '│', style);
666
+ }
667
+ this.putStr(y + h - 1, x, '└' + '─'.repeat(w - 2) + '┘', style);
668
+ if (title) {
669
+ this.putStr(y, x + 2, ` ${title} `, titleStyle);
670
+ }
671
+ }
672
+
673
+ renderToAnsi() {
674
+ let out = `${CSI}H`;
675
+ for (let r = 0; r < this.rows; r++) {
676
+ let curStyle = '';
677
+ let line = '';
678
+ for (let c = 0; c < this.cols - 1; c++) {
679
+ const st = this.styles[r][c];
680
+ if (st !== curStyle) {
681
+ line += ANSI.reset + st;
682
+ curStyle = st;
683
+ }
684
+ line += this.chars[r][c];
685
+ }
686
+ line += ANSI.reset;
687
+ if (r < this.rows - 1) line += '\r\n';
688
+ out += line;
689
+ }
690
+ return out;
691
+ }
692
+ }
693
+
694
+ class EvaNodeTUI {
695
+ constructor(opts, manager) {
696
+ this.opts = opts;
697
+ this.manager = manager;
698
+ this.manager.onUpdate = () => this.render();
699
+
700
+ this.activeTab = 1; // 1: Download, 2: Queue, 3: Library, 4: Settings, 5: About
701
+ this.focusId = 'url_input';
702
+ this.focusOrder = [];
703
+ this.hitboxes = [];
704
+ this.selectedQueueIdx = 0;
705
+ this.statusBanner = '';
706
+
707
+ // Inline modal prompt state
708
+ this.modal = null; // { label, value, cursor, onConfirm }
709
+ }
710
+
711
+ start() {
712
+ if (process.stdin.isTTY) {
713
+ process.stdin.setRawMode(true);
714
+ }
715
+ process.stdin.resume();
716
+ process.stdin.setEncoding('utf8');
717
+
718
+ process.stdout.write(ANSI.altScreenOn + ANSI.hideCursor + ANSI.mouseOn + ANSI.clearScreen);
719
+
720
+ process.stdout.on('resize', () => this.render());
721
+ process.stdin.on('data', (data) => this.handleInput(data));
722
+ process.on('SIGINT', () => this.quit());
723
+ process.on('SIGTERM', () => this.quit());
724
+
725
+ this.render();
726
+ }
727
+
728
+ quit() {
729
+ process.stdout.write(ANSI.mouseOff + ANSI.showCursor + ANSI.altScreenOff + ANSI.reset + '\n');
730
+ process.exit(0);
731
+ }
732
+
733
+ registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
734
+ this.focusOrder.push(id);
735
+ const isFocused = this.focusId === id;
736
+
737
+ // Префикс и его цвет
738
+ let prefix = '';
739
+ let prefixStyle = ANSI.fgWhite;
740
+
741
+ if (checked !== null) {
742
+ prefix = checked ? '[x] ' : '[ ] ';
743
+ prefixStyle = checked
744
+ ? ANSI.fgGreen + ANSI.bold
745
+ : ANSI.dim + ANSI.fgWhite;
746
+ } else if (radio !== null) {
747
+ prefix = radio ? '(•) ' : '( ) ';
748
+ prefixStyle = radio
749
+ ? ANSI.fgCyan + ANSI.bold
750
+ : ANSI.dim + ANSI.fgWhite;
751
+ }
752
+
753
+ // Стиль основного текста
754
+ let style = ANSI.fgGray;
755
+
756
+ if (isFocused) {
757
+ style = isCta
758
+ ? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
759
+ : ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
760
+ } else if (isCta) {
761
+ style = id === 'url_input'
762
+ ? ANSI.fgWhite + ANSI.bold
763
+ : id === 'btn_add_queue'
764
+ ? ANSI.fgGreen + ANSI.bold
765
+ : ANSI.fgCyan + ANSI.bold;
766
+ } else if (checked || radio) {
767
+ style = ANSI.fgOrange + ANSI.bold;
768
+ }
769
+
770
+ // Вывод префикса с раздельными цветами
771
+ if (checked !== null) {
772
+ scr.putStr(y, x, '[', ANSI.fgGray);
773
+ scr.putStr(y, x + 1, checked ? 'x' : ' ', checked ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
774
+ scr.putStr(y, x + 2, '] ', ANSI.fgGray);
775
+ } else if (radio !== null) {
776
+ scr.putStr(y, x, '(', ANSI.fgGray);
777
+ scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
778
+ scr.putStr(y, x + 2, ') ', ANSI.fgGray);
779
+ }
780
+
781
+ // Расчёт доступной ширины текста
782
+ const availableWidth = maxWidth > 0
783
+ ? Math.max(0, maxWidth - prefix.length)
784
+ : Infinity;
785
+
786
+ let visibleLabel = label;
787
+
788
+ if (visibleLabel.length > availableWidth) {
789
+ visibleLabel = availableWidth > 0
790
+ ? visibleLabel.slice(0, availableWidth - 1) + '…'
791
+ : '';
792
+ }
793
+
794
+ // Вывод основного текста
795
+ const textX = x + (prefix ? prefix.length : 0);
796
+ const editMarker = '[ред.]';
797
+ const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
798
+ if (!isFocused && editMarkerIndex >= 0) {
799
+ const folderLabel = 'Папка:';
800
+ const folderLabelIndex = visibleLabel.indexOf(folderLabel);
801
+ if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
802
+ scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
803
+ scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
804
+ const afterFolderIndex = folderLabelIndex + folderLabel.length;
805
+ scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
806
+ } else {
807
+ scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
808
+ }
809
+ scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
810
+ scr.putStr(y, textX + editMarkerIndex + editMarker.length, visibleLabel.slice(editMarkerIndex + editMarker.length), style);
811
+ } else {
812
+ scr.putStr(y, textX, visibleLabel, style);
813
+ }
814
+
815
+ // Область взаимодействия
816
+ const totalLength = (prefix ? prefix.length : 0) + visibleLabel.length;
817
+
818
+ this.hitboxes.push({
819
+ id,
820
+ y,
821
+ x1: x,
822
+ x2: x + totalLength - 1,
823
+ action
824
+ });
825
+
826
+ return x + totalLength;
827
+ }
828
+
829
+
830
+ openPrompt(label, currentVal, onConfirm) {
831
+ const val = String(currentVal || '');
832
+ this.modal = {
833
+ label,
834
+ value: val,
835
+ cursor: val.length,
836
+ onConfirm,
837
+ };
838
+ this.render();
839
+ }
840
+
841
+ // -------------------------------------------------------------------------
842
+ // Rendering
843
+ // -------------------------------------------------------------------------
844
+
845
+ render() {
846
+ const cols = process.stdout.columns || 100;
847
+ const rows = process.stdout.rows || 28;
848
+ const scr = new ScreenBuffer(cols, rows);
849
+
850
+ this.focusOrder = [];
851
+ this.hitboxes = [];
852
+
853
+ if (rows < 22 || cols < 76) {
854
+ scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
855
+ process.stdout.write(scr.renderToAnsi());
856
+ return;
857
+ }
858
+
859
+ // 1. Header Logo & Tagline
860
+ ASCII_LOGO.forEach((line, i) => {
861
+ scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
862
+ });
863
+ scr.putStr(7, 2, 'EVA MEDIA DOWNLOADER — by .maxon', ANSI.fgCyan);
864
+ scr.putStr(2, cols - APP_VERSION.length - 3, APP_VERSION, ANSI.fgWhite);
865
+ if (cols >= 90) {
866
+ const tagline = 'Music. Videos. Your collection.';
867
+ scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
868
+ scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
869
+ }
870
+
871
+ // 2. Left Sidebar Navigation (Strictly 1-cell narrow CP437/WGL4 characters so borders NEVER shift)
872
+ const sidebarW = 19;
873
+ const navItems = [
874
+ { tab: 1, label: '≡ General' },
875
+ { tab: 2, label: `↓ Download (${this.manager.items.length})` },
876
+ { tab: 3, label: '♫ Library' },
877
+ { tab: 4, label: '* Settings' },
878
+ { tab: 5, label: 'i About' },
879
+ ];
880
+
881
+ navItems.forEach((nav, idx) => {
882
+ const r = 9 + idx * 2;
883
+ const isActive = this.activeTab === nav.tab;
884
+ const padded = ` ${nav.label.padEnd(sidebarW - 3, ' ')} `;
885
+ const st = isActive ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold : ANSI.fgWhite;
886
+ scr.putStr(r, 2, padded, st);
887
+ this.hitboxes.push({
888
+ id: `tab_${nav.tab}`,
889
+ y: r,
890
+ x1: 2,
891
+ x2: 2 + padded.length - 1,
892
+ action: () => {
893
+ this.activeTab = nav.tab;
894
+ this.render();
895
+ },
896
+ });
897
+ });
898
+
899
+ for (let r = 8; r < rows - 2; r++) {
900
+ scr.putStr(r, sidebarW + 1, '│', ANSI.fgWhite);
901
+ }
902
+
903
+ // 3. Main Content Area
904
+ const mainX = sidebarW + 3;
905
+ const mainW = cols - mainX - 2;
906
+
907
+ if (this.activeTab === 1) this._renderDownloadTab(scr, mainX, mainW, rows);
908
+ else if (this.activeTab === 2) this._renderQueueTab(scr, mainX, mainW, rows);
909
+ else if (this.activeTab === 3) this._renderLibraryTab(scr, mainX, mainW, rows);
910
+ else if (this.activeTab === 4) this._renderSettingsTab(scr, mainX, mainW, rows);
911
+ else if (this.activeTab === 5) this._renderAboutTab(scr, mainX, mainW, rows);
912
+
913
+ if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
914
+ this.focusId = this.focusOrder[0];
915
+ }
916
+
917
+ // 4. Bottom Status Bar with Live Queue Counter & Progress Bar (or Modal Input)
918
+ if (this.modal) {
919
+ const prefix = ` * ${this.modal.label}: `;
920
+ scr.putStr(rows - 2, 0, prefix, ANSI.bgCyan + ANSI.fgBlack + ANSI.bold);
921
+ scr.putStr(rows - 2, prefix.length, this.modal.value.padEnd(Math.max(1, cols - prefix.length - 1), ' '), ANSI.fgYellow + ANSI.bold);
922
+ scr.putStr(rows - 1, 0, ' [Enter] Применить [Esc] Отмена [Ctrl+U] Очистить '.padEnd(cols - 1, ' '), ANSI.bgWhite + ANSI.fgBlack);
923
+ } else {
924
+ const footerLeft = 'EVA.md • yt-dlp';
925
+ scr.putStr(rows - 1, 2, footerLeft, ANSI.fgCyan + ANSI.bold);
926
+
927
+ const items = this.manager.items;
928
+ const activeItems = items.filter((i) => i.status === 'Downloading');
929
+ const doneCnt = items.filter((i) => i.status === 'Done').length;
930
+
931
+ let pct = 0;
932
+ if (activeItems.length > 0) {
933
+ const sum = activeItems.reduce((acc, i) => acc + (i.progress || 0), 0);
934
+ pct = Math.round(sum / activeItems.length);
935
+ } else if (items.length > 0 && doneCnt === items.length) {
936
+ pct = 100;
937
+ }
938
+
939
+ const barW = 12;
940
+ const filled = Math.round((pct / 100) * barW);
941
+ const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
942
+
943
+ const queueStatusStr = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
944
+ const qStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
945
+ const qStartX = cols - queueStatusStr.length - 0;
946
+ scr.putStr(rows - 1, qStartX, queueStatusStr, qStyle);
947
+
948
+ const afterQueueX = qStartX + queueStatusStr.length + 2;
949
+ if (this.statusBanner && afterQueueX < cols - 10) {
950
+ scr.putStr(rows - 1, afterQueueX, `│ ${this.statusBanner}`.slice(0, cols - afterQueueX - 2), ANSI.fgCyan);
951
+ }
952
+ }
953
+
954
+ process.stdout.write(scr.renderToAnsi());
955
+ }
956
+
957
+ // -------------------------------------------------------------------------
958
+ // Tab 1: Compact Main Download Screen (Word-wrapped CLI, no bottom queue)
959
+ // -------------------------------------------------------------------------
960
+
961
+ _renderDownloadTab(scr, x, w, rows) {
962
+ let y = 8;
963
+
964
+ // --- Box 1: Select a source (1-9) & URL input ---
965
+ scr.drawBox(y, x, 7, w, 'Select a source');
966
+ const colWidth = Math.floor(w / 3);
967
+ const col1X = x + 2;
968
+ const col2X = x + colWidth;
969
+ const col3X = x + colWidth * 2;
970
+
971
+ SOURCES.forEach((s) => {
972
+ const colIndex = Math.floor((s.id - 1) / 3);
973
+ const rowIndex = (s.id - 1) % 3;
974
+
975
+ const sy = y + 1 + rowIndex;
976
+ const sx = colIndex === 0 ? col1X : colIndex === 1 ? col2X : col3X;
977
+
978
+ this.registerWidget(
979
+ scr,
980
+ `src_${s.id}`,
981
+ sy,
982
+ sx,
983
+ `${s.code} ${s.icon} ${s.name}`,
984
+ () => this._selectSource(s.id),
985
+ { radio: this.opts.sourceId === s.id, maxWidth: Math.floor(w / 2) - 3 }
986
+ );
987
+ });
988
+
989
+ scr.putStr(y + 4, x + 0, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
990
+ const srcObj = SOURCES.find((s) => s.id === this.opts.sourceId) || SOURCES[0];
991
+ const urlDisp = this.opts.url ? this.opts.url : `(нажмите 'u' или кликните для ввода URL — ${srcObj.hint})`;
992
+ this.registerWidget(
993
+ scr,
994
+ 'url_input',
995
+ y + 5,
996
+ x + 2,
997
+ `URL > ${urlDisp}`,
998
+ () => this.openPrompt('Вставьте ссылку на трек или плейлист', this.opts.url, (val) => this._setUrl(val)),
999
+ { isCta: true, maxWidth: w - 4 }
1000
+ );
1001
+
1002
+ y += 7;
1003
+
1004
+ // --- Box 2: Download Options & yt-dlp Flags ---
1005
+ const box2H = 11;
1006
+ scr.drawBox(y, x, box2H, w, 'Download Options Flags');
1007
+ const c1 = x + 2;
1008
+
1009
+ // Row 1: Audio (-x, metadata, thumbnail)
1010
+ scr.putStr(y + 1, c1, 'АУДИО:', ANSI.fgCyan + ANSI.bold);
1011
+ let ax = c1 + 7;
1012
+ ax =
1013
+ this.registerWidget(
1014
+ scr,
1015
+ 'flag_x',
1016
+ y + 1,
1017
+ ax,
1018
+ 'Только аудио',
1019
+ () => this._toggle('extractAudio'),
1020
+ { checked: this.opts.extractAudio }
1021
+ ) + 2;
1022
+ ax =
1023
+ this.registerWidget(
1024
+ scr,
1025
+ 'flag_meta',
1026
+ y + 1,
1027
+ ax,
1028
+ 'Встроить метаданные',
1029
+ () => this._toggle('embedMetadata'),
1030
+ { checked: this.opts.embedMetadata }
1031
+ ) + 2;
1032
+ this.registerWidget(
1033
+ scr,
1034
+ 'flag_thumb',
1035
+ y + 1,
1036
+ ax,
1037
+ 'Встроить обложку',
1038
+ () => this._toggle('embedThumbnail'),
1039
+ { checked: this.opts.embedThumbnail, maxWidth: x + w - ax - 2 }
1040
+ );
1041
+
1042
+ // Row 2: Format (--audio-format)
1043
+ scr.putStr(y + 2, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
1044
+ let fx = c1 + 8;
1045
+ AUDIO_FORMATS.forEach((fmt) => {
1046
+ fx =
1047
+ this.registerWidget(
1048
+ scr,
1049
+ `fmt_${fmt}`,
1050
+ y + 2,
1051
+ fx,
1052
+ fmt,
1053
+ () => {
1054
+ this.opts.audioFormat = fmt;
1055
+ this.opts.extractAudio = true;
1056
+ this.opts.bestVideoMerge = false;
1057
+ this.statusBanner = `Формат: --audio-format ${fmt}`;
1058
+ this.render();
1059
+ },
1060
+ { radio: this.opts.audioFormat === fmt }
1061
+ ) + 2;
1062
+ });
1063
+
1064
+ // Row 3: Quality (--audio-quality)
1065
+ scr.putStr(y + 3, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
1066
+ let qx = c1 + 9;
1067
+ AUDIO_QUALITIES.forEach((q) => {
1068
+ qx =
1069
+ this.registerWidget(
1070
+ scr,
1071
+ `qual_${q.val}`,
1072
+ y + 3,
1073
+ qx,
1074
+ q.label,
1075
+ () => {
1076
+ this.opts.audioQuality = q.val;
1077
+ this.statusBanner = `Качество: --audio-quality ${q.val}`;
1078
+ this.render();
1079
+ },
1080
+ { radio: this.opts.audioQuality === q.val }
1081
+ ) + 2;
1082
+ });
1083
+
1084
+ // Row 4: Video quality and subtitle file
1085
+ scr.putStr(y + 4, c1, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
1086
+ let vx = c1 + 7;
1087
+ vx =
1088
+ this.registerWidget(
1089
+ scr,
1090
+ 'flag_bvbab',
1091
+ y + 4,
1092
+ vx,
1093
+ 'Видео+Аудио',
1094
+ () => {
1095
+ this.opts.bestVideoMerge = !this.opts.bestVideoMerge;
1096
+ this.opts.extractAudio = !this.opts.bestVideoMerge;
1097
+ this.statusBanner = this.opts.bestVideoMerge ? 'Режим видео -f "bv*+ba/b"' : 'Режим аудио -x';
1098
+ this.render();
1099
+ },
1100
+ { checked: this.opts.bestVideoMerge }
1101
+ ) + 2;
1102
+ vx =
1103
+ this.registerWidget(scr, 'flag_wsubs', y + 4, vx, 'Файл субтитров', () => this._toggle('writeSubs'), {
1104
+ checked: this.opts.writeSubs,
1105
+ }) + 2;
1106
+ let mediaOptionsX =
1107
+ this.registerWidget(scr, 'flag_esubs', y + 5, c1 + 7, 'Встроить субтитры', () => this._toggle('embedSubs'), {
1108
+ checked: this.opts.embedSubs,
1109
+ }) + 2;
1110
+ this.registerWidget(scr, 'flag_wthumb', y + 5, mediaOptionsX, 'Скачать обложку', () => this._toggle('writeThumbnail'), {
1111
+ checked: this.opts.writeThumbnail,
1112
+ maxWidth: x + w - mediaOptionsX - 2,
1113
+ });
1114
+
1115
+ // Row 6: Naming & Archive (-o & --download-archive)
1116
+ scr.putStr(y + 6, c1, 'ФАЙЛЫ:', ANSI.fgCyan + ANSI.bold);
1117
+ const maxTplLen = Math.max(14, Math.floor((w - 20) / 4));
1118
+ const shortTpl =
1119
+ this.opts.outputTemplate.length > maxTplLen
1120
+ ? this.opts.outputTemplate.slice(0, maxTplLen - 1) + '…'
1121
+ : this.opts.outputTemplate;
1122
+ let nx =
1123
+ this.registerWidget(
1124
+ scr,
1125
+ 'flag_otpl',
1126
+ y + 6,
1127
+ c1 + 9,
1128
+ `-o "${shortTpl}"`,
1129
+ () => this._toggle('usePlaylistTemplate'),
1130
+ { checked: this.opts.usePlaylistTemplate }
1131
+ ) + 1;
1132
+ nx =
1133
+ this.registerWidget(scr, 'edit_otpl', y + 6, nx, '[ред.]', () =>
1134
+ this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1135
+ this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1136
+ })
1137
+ ) + 3;
1138
+
1139
+ let arx =
1140
+ this.registerWidget(
1141
+ scr,
1142
+ 'flag_archive',
1143
+ y + 6,
1144
+ nx,
1145
+ `История загрузки ${this.opts.archiveFile}`,
1146
+ () => this._toggle('useDownloadArchive'),
1147
+ { checked: this.opts.useDownloadArchive }
1148
+ ) + 1;
1149
+ this.registerWidget(scr, 'edit_archive', y + 6, arx, '[ред.]', () =>
1150
+ this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1151
+ this.opts.archiveFile = v || 'archive.txt';
1152
+ })
1153
+ );
1154
+
1155
+ // Row 7: Playlists (--playlist-items & --playlist-reverse)
1156
+ scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
1157
+ let px =
1158
+ this.registerWidget(
1159
+ scr,
1160
+ 'flag_plitems',
1161
+ y + 7,
1162
+ c1 + 9,
1163
+ `Выбрать файлы ${this.opts.playlistItems}`,
1164
+ () => this._toggle('usePlaylistItems'),
1165
+ { checked: this.opts.usePlaylistItems }
1166
+ ) + 1;
1167
+ px =
1168
+ this.registerWidget(scr, 'edit_plitems', y + 7, px, '[ред.]', () =>
1169
+ this.openPrompt('Номера треков (--playlist-items, напр. 1-5)', this.opts.playlistItems, (v) => {
1170
+ this.opts.playlistItems = v || '1-5';
1171
+ })
1172
+ ) + 3;
1173
+
1174
+ this.registerWidget(
1175
+ scr,
1176
+ 'flag_plrev',
1177
+ y + 7,
1178
+ px,
1179
+ 'Загрузить плейлист с конца',
1180
+ () => this._toggle('playlistReverse'),
1181
+ { checked: this.opts.playlistReverse, maxWidth: x + w - px - 2 }
1182
+ );
1183
+
1184
+ // Row 9: Output directory & Add to Queue CTA button
1185
+ const maxDirLen = Math.max(16, w - 48);
1186
+ const shortDir =
1187
+ this.opts.outputDir.length > maxDirLen
1188
+ ? '…' + this.opts.outputDir.slice(-(maxDirLen - 1))
1189
+ : this.opts.outputDir;
1190
+ let dx =
1191
+ this.registerWidget(scr, 'edit_outdir', y + 9, c1, `Папка: ${shortDir} [ред.]`, () =>
1192
+ this.openPrompt('Папка сохранения', this.opts.outputDir, (v) => {
1193
+ this.opts.outputDir = v || path.join(os.homedir(), 'Downloads');
1194
+ })
1195
+ ) + 3;
1196
+
1197
+ const ctaLabel = 'Скачать (Enter / a)';
1198
+ const ctaX = Math.max(dx, x + w - ctaLabel.length - 3);
1199
+ this.registerWidget(scr, 'btn_add_queue', y + 9, ctaX, ctaLabel, () => this._actionAddToQueue(), {
1200
+ isCta: true,
1201
+ });
1202
+
1203
+ y += box2H;
1204
+
1205
+ // Word-wrapped CLI Preview Lines (never overflows the window width)
1206
+ const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
1207
+ const wrappedCli = wrapTextByWords(fullCliText, w - 2);
1208
+ const availCliRows = Math.max(1, rows - 2 - y);
1209
+ wrappedCli.slice(0, availCliRows).forEach((line, idx) => {
1210
+ scr.putStr(y + idx, x + 1, line, ANSI.fgYellow);
1211
+ });
1212
+ }
1213
+
1214
+ // -------------------------------------------------------------------------
1215
+ // Tab 2: Queue Tab (Full Queue Table + Live yt-dlp Output Log)
1216
+ // -------------------------------------------------------------------------
1217
+
1218
+ _renderQueueBox(scr, y, x, h, w) {
1219
+ scr.drawBox(y, x, h, w, 'Download Queue');
1220
+ const titleColW = Math.max(18, w - 42);
1221
+ const hdr = ` # ${'Title'.padEnd(titleColW)} ${'Format'.padEnd(7)} ${'Size'.padEnd(10)} Status`;
1222
+ scr.putStr(y + 1, x + 2, hdr.slice(0, w - 4), ANSI.fgCyan + ANSI.bold);
1223
+ scr.putStr(y + 2, x + 1, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1224
+
1225
+ const items = this.manager.items;
1226
+ if (this.selectedQueueIdx >= items.length) {
1227
+ this.selectedQueueIdx = Math.max(0, items.length - 1);
1228
+ }
1229
+
1230
+ const listRows = Math.max(1, h - 5);
1231
+ if (items.length === 0) {
1232
+ scr.putStr(y + 3, x + 4, 'Очередь пуста. Добавьте трек во вкладке [1] Download.', ANSI.fgWhite);
1233
+ } else {
1234
+ const startI = Math.max(0, this.selectedQueueIdx - listRows + 1);
1235
+ const slice = items.slice(startI, startI + listRows);
1236
+ slice.forEach((it, rowOffset) => {
1237
+ const idx = startI + rowOffset;
1238
+ const ry = y + 3 + rowOffset;
1239
+ const isSel = idx === this.selectedQueueIdx;
1240
+ const pointer = isSel ? '►' : ' ';
1241
+ const barW = 10;
1242
+ const filled = Math.round((it.progress / 100) * barW);
1243
+ const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
1244
+ const pct = `${String(Math.round(it.progress)).padStart(3, ' ')}%`;
1245
+ let statusText = `[${bar}] ${pct} ${it.status}`;
1246
+ if (it.status === 'Error' && it.errorMsg) {
1247
+ statusText = `ERR: ${it.errorMsg.slice(0, 18)}`;
1248
+ }
1249
+
1250
+ const seqNum = idx + 1;
1251
+ const line = `${pointer}${String(seqNum).padEnd(2)} ${it.title.padEnd(titleColW).slice(0, titleColW)} ${it.fmt
1252
+ .padEnd(7)
1253
+ .slice(0, 7)} ${it.sizeStr.padEnd(10).slice(0, 10)} ${statusText}`;
1254
+ const st = isSel
1255
+ ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold
1256
+ : it.status === 'Done'
1257
+ ? ANSI.fgGreen
1258
+ : it.status === 'Error'
1259
+ ? ANSI.fgRed
1260
+ : ANSI.fgWhite;
1261
+
1262
+ scr.putStr(ry, x + 2, line.slice(0, w - 4), st);
1263
+ this.hitboxes.push({
1264
+ id: `qitem_${idx}`,
1265
+ y: ry,
1266
+ x1: x + 2,
1267
+ x2: x + w - 3,
1268
+ action: () => {
1269
+ this.selectedQueueIdx = idx;
1270
+ this.render();
1271
+ },
1272
+ });
1273
+ });
1274
+ }
1275
+
1276
+ const ctrlY = y + h - 2;
1277
+ scr.putStr(ctrlY - 1, x + 1, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1278
+ let cx = x + 3;
1279
+ cx = this.registerWidget(scr, 'q_resume', ctrlY, cx, '[r] Resume', () => this._qAction('resume')) + 2;
1280
+ cx = this.registerWidget(scr, 'q_pause', ctrlY, cx, '[p] Pause', () => this._qAction('pause')) + 2;
1281
+ cx = this.registerWidget(scr, 'q_cancel', ctrlY, cx, '[c] Cancel', () => this._qAction('cancel')) + 2;
1282
+ cx = this.registerWidget(scr, 'q_remove', ctrlY, cx, '[d] Remove', () => this._qAction('remove')) + 2;
1283
+ const activeCnt = items.filter((i) => i.status === 'Downloading').length;
1284
+ scr.putStr(ctrlY, cx, `│ Active: ${activeCnt}/${items.length}`.slice(0, Math.max(0, x + w - cx - 2)), ANSI.fgCyan);
1285
+ }
1286
+
1287
+ _renderQueueTab(scr, x, w, rows) {
1288
+ const queueH = Math.max(8, rows - 16);
1289
+ this._renderQueueBox(scr, 8, x, queueH, w);
1290
+ const logY = 8 + queueH;
1291
+ const logH = Math.max(4, rows - logY - 1);
1292
+ scr.drawBox(logY, x, logH, w, 'Selected Task yt-dlp Output Log');
1293
+ const it = this.manager.items[this.selectedQueueIdx];
1294
+ if (it) {
1295
+ scr.putStr(logY + 1, x + 2, `CMD: yt-dlp ${it.cmdArgs.map(shellQuote).join(' ')}`.slice(0, w - 4), ANSI.fgYellow);
1296
+ const maxLogLines = Math.max(1, logH - 3);
1297
+ it.logLines.slice(-maxLogLines).forEach((l, i) => {
1298
+ scr.putStr(logY + 2 + i, x + 2, l.slice(0, w - 4), ANSI.fgWhite);
1299
+ });
1300
+ } else {
1301
+ scr.putStr(logY + 2, x + 2, 'Выберите задачу в очереди для просмотра лога yt-dlp.', ANSI.fgWhite);
1302
+ }
1303
+ }
1304
+
1305
+ // -------------------------------------------------------------------------
1306
+ // Tab 3: Library Tab
1307
+ // -------------------------------------------------------------------------
1308
+
1309
+ _renderLibraryTab(scr, x, w, rows) {
1310
+ const h = rows - 9;
1311
+ scr.drawBox(8, x, h, w, `Downloaded Media Library (${this.opts.outputDir})`);
1312
+ const outDir = this.opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
1313
+ const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
1314
+ let files = [];
1315
+ try {
1316
+ if (fs.existsSync(outDir)) {
1317
+ files = fs
1318
+ .readdirSync(outDir)
1319
+ .map((name) => {
1320
+ const full = path.join(outDir, name);
1321
+ const stat = fs.statSync(full);
1322
+ return { name, full, stat, ext: path.extname(name).toLowerCase() };
1323
+ })
1324
+ .filter((f) => f.stat.isFile() && exts.has(f.ext))
1325
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
1326
+ }
1327
+ } catch (_) {}
1328
+
1329
+ const nameColW = Math.max(20, w - 28);
1330
+ if (files.length === 0) {
1331
+ scr.putStr(10, x + 3, `В папке ${outDir} пока нет скачанных медиафайлов.`.slice(0, w - 6), ANSI.fgWhite);
1332
+ } else {
1333
+ scr.putStr(
1334
+ 10,
1335
+ x + 3,
1336
+ `${'#'.padEnd(4)} ${'Имя файла'.padEnd(nameColW)} ${'Формат'.padEnd(7)} Размер`.slice(0, w - 6),
1337
+ ANSI.fgCyan + ANSI.bold
1338
+ );
1339
+ files.slice(0, h - 5).forEach((f, i) => {
1340
+ const mb = formatBytesToMB(f.stat.size);
1341
+ const line = `${String(i + 1).padEnd(4)} ${f.name.padEnd(nameColW).slice(0, nameColW)} ${f.ext
1342
+ .slice(1)
1343
+ .padEnd(7)} ${mb}`;
1344
+ scr.putStr(12 + i, x + 3, line.slice(0, w - 6), ANSI.fgWhite);
1345
+ });
1346
+ }
1347
+ }
1348
+
1349
+ // -------------------------------------------------------------------------
1350
+ // Tab 4: Settings Tab (Network & Bypass flags + Naming Presets)
1351
+ // -------------------------------------------------------------------------
1352
+
1353
+ _renderSettingsTab(scr, x, w, rows) {
1354
+ const h = rows - 9;
1355
+ scr.drawBox(8, x, h, w, 'Settings & Network Options');
1356
+ const c1 = x + 3;
1357
+
1358
+ scr.putStr(10, c1, 'СЕТЬ И ОБХОД БЛОКИРОВОК:', ANSI.fgCyan + ANSI.bold);
1359
+ this.registerWidget(
1360
+ scr,
1361
+ 'flag_ipv4',
1362
+ 11,
1363
+ c1 + 2,
1364
+ 'Принудительный IPv4 (Решает ошибки DNS)',
1365
+ () => this._toggle('forceIpv4'),
1366
+ { checked: this.opts.forceIpv4, maxWidth: w - 8 }
1367
+ );
1368
+
1369
+ let px =
1370
+ this.registerWidget(
1371
+ scr,
1372
+ 'flag_proxy',
1373
+ 12,
1374
+ c1 + 2,
1375
+ `Прокси ${this.opts.proxyUrl}`,
1376
+ () => this._toggle('useProxy'),
1377
+ { checked: this.opts.useProxy }
1378
+ ) + 2;
1379
+ this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () =>
1380
+ this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
1381
+ this.opts.proxyUrl = v || 'http://127.0.0.1:8080';
1382
+ })
1383
+ );
1384
+
1385
+ let ckx =
1386
+ this.registerWidget(
1387
+ scr,
1388
+ 'flag_cookies',
1389
+ 13,
1390
+ c1 + 2,
1391
+ `Брать cookies из браузера ${this.opts.cookiesBrowser}`,
1392
+ () => this._toggle('useCookiesBrowser'),
1393
+ { checked: this.opts.useCookiesBrowser }
1394
+ ) + 2;
1395
+ this.registerWidget(scr, 'cycle_browser', 13, ckx, '[сменить браузер]', () => {
1396
+ const idx = BROWSERS.indexOf(this.opts.cookiesBrowser);
1397
+ const nextB = BROWSERS[(idx + 1) % BROWSERS.length];
1398
+ this.opts.cookiesBrowser = nextB;
1399
+ this.opts.useCookiesBrowser = nextB !== 'none';
1400
+ this.statusBanner = `Cookies браузер: ${nextB}`;
1401
+ this.render();
1402
+ });
1403
+
1404
+ scr.putStr(15, c1, 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ (-o):', ANSI.fgCyan + ANSI.bold);
1405
+ NAMING_PRESETS.forEach((p, idx) => {
1406
+ this.registerWidget(
1407
+ scr,
1408
+ `preset_${idx}`,
1409
+ 16 + idx,
1410
+ c1 + 2,
1411
+ p.label,
1412
+ () => {
1413
+ this.opts.outputTemplate = p.tpl;
1414
+ this.opts.usePlaylistTemplate = true;
1415
+ this.statusBanner = `Шаблон: ${p.tpl}`;
1416
+ this.render();
1417
+ },
1418
+ { radio: this.opts.outputTemplate === p.tpl, maxWidth: w - 8 }
1419
+ );
1420
+ });
1421
+
1422
+ scr.putStr(20, c1, 'ОКРУЖЕНИЕ:', ANSI.fgCyan + ANSI.bold);
1423
+ scr.putStr(
1424
+ 21,
1425
+ c1 + 2,
1426
+ `Node.js : ${process.version} (${process.platform}/${process.arch})`.slice(0, w - 8),
1427
+ ANSI.fgGreen
1428
+ );
1429
+ scr.putStr(
1430
+ 22,
1431
+ c1 + 2,
1432
+ `yt-dlp : ${this.manager.ytdlpPath || 'НЕ НАЙДЕН (Установите через winget/brew/apt)'}`.slice(0, w - 8),
1433
+ this.manager.ytdlpPath ? ANSI.fgGreen : ANSI.fgRed
1434
+ );
1435
+ scr.putStr(
1436
+ 23,
1437
+ c1 + 2,
1438
+ `ffmpeg : ${this.manager.ffmpegPath || 'НЕ НАЙДЕН (Нужен для конвертации в mp3/flac)'}`.slice(0, w - 8),
1439
+ this.manager.ffmpegPath ? ANSI.fgGreen : ANSI.fgYellow
1440
+ );
1441
+
1442
+ if (8 + h - 2 >= 25) {
1443
+ this.registerWidget(
1444
+ scr,
1445
+ 'btn_save_cfg',
1446
+ Math.min(25, 8 + h - 2),
1447
+ c1,
1448
+ '[ Сохранить текущие настройки по умолчанию ]',
1449
+ () => {
1450
+ try {
1451
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
1452
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.opts, null, 2), 'utf8');
1453
+ this.statusBanner = `Настройки сохранены`;
1454
+ } catch (err) {
1455
+ this.statusBanner = `Ошибка: ${err.message}`;
1456
+ }
1457
+ this.render();
1458
+ },
1459
+ { isCta: true }
1460
+ );
1461
+ }
1462
+ }
1463
+
1464
+ // -------------------------------------------------------------------------
1465
+ // Tab 5: About Tab
1466
+ // -------------------------------------------------------------------------
1467
+
1468
+ _renderAboutTab(scr, x, w, rows) {
1469
+ const h = rows - 9;
1470
+ scr.drawBox(8, x, h, w, 'About EVA.md (Node.js Edition)');
1471
+ const lines = [
1472
+ `EVA.md ${APP_VERSION} — Консольный загрузчик музыки и видео`,
1473
+ '',
1474
+ 'Поддерживаемые площадки (9 источников):',
1475
+ ' 01. YouTube 04. SoundCloud 07. Audius',
1476
+ ' 02. YouTube Music 05. Mixcloud 08. ВКонтакте (VK)',
1477
+ ' 03. Bandcamp 06. Audiomack 09. Internet Archive',
1478
+ '',
1479
+ 'Интегрированные флаги yt-dlp:',
1480
+ ' • Аудио: -x, --audio-format (mp3/flac/m4a/wav), --audio-quality 0, --embed-metadata, --embed-thumbnail',
1481
+ ' • Видео: -f "bv*+ba/b", --write-subs, --embed-subs, --write-thumbnail, --merge-output-format mp4',
1482
+ ' • Файлы: -o "%(playlist_index)s. %(title)s.%(ext)s", --download-archive archive.txt',
1483
+ ' • Плейлисты: --playlist-items 1-5, --playlist-reverse',
1484
+ ' • Сеть (вкладка Settings): -4, --proxy http://127.0.0.1:8080, --cookies-from-browser chrome',
1485
+ ];
1486
+ lines.forEach((line, i) => {
1487
+ if (10 + i < rows - 2) scr.putStr(10 + i, x + 3, line.slice(0, w - 6), ANSI.fgWhite);
1488
+ });
1489
+ }
1490
+
1491
+ // -------------------------------------------------------------------------
1492
+ // Actions & Input Handling
1493
+ // -------------------------------------------------------------------------
1494
+
1495
+ _selectSource(sid) {
1496
+ this.opts.sourceId = sid;
1497
+ const s = SOURCES.find((item) => item.id === sid) || SOURCES[0];
1498
+ if (sid === 8 && !this.opts.useCookiesBrowser) {
1499
+ this.opts.useCookiesBrowser = true;
1500
+ this.statusBanner = `${s.name} (включен --cookies-from-browser)`;
1501
+ } else if (sid === 1 && this.opts.url.trim()) {
1502
+ const detected = detectSource(this.opts.url, sid);
1503
+ this.statusBanner = `URL (${detected.name})`;
1504
+ } else {
1505
+ this.statusBanner = `Источник: ${s.name}`;
1506
+ }
1507
+ this.render();
1508
+ }
1509
+
1510
+ _setUrl(val) {
1511
+ this.opts.url = val;
1512
+ if (val) {
1513
+ const detected = detectSource(val, this.opts.sourceId);
1514
+ if (this.opts.sourceId !== 1) {
1515
+ this.opts.sourceId = detected.id;
1516
+ }
1517
+ this.statusBanner = `URL (${detected.name})`;
1518
+ }
1519
+ this.render();
1520
+ }
1521
+
1522
+ _toggle(key) {
1523
+ this.opts[key] = !this.opts[key];
1524
+ this.render();
1525
+ }
1526
+
1527
+ _actionAddToQueue() {
1528
+ if (!this.opts.url.trim()) {
1529
+ this.openPrompt('Сначала вставьте ссылку (URL)', this.opts.url, (val) => {
1530
+ this._setUrl(val);
1531
+ if (this.opts.url.trim()) {
1532
+ const it = this.manager.addTask(this.opts, true);
1533
+ this.selectedQueueIdx = this.manager.items.length - 1;
1534
+ this.statusBanner = `+ #${this.manager.items.length} (${it.sourceName})`;
1535
+ this.render();
1536
+ }
1537
+ });
1538
+ return;
1539
+ }
1540
+ const item = this.manager.addTask(this.opts, true);
1541
+ this.selectedQueueIdx = this.manager.items.length - 1;
1542
+ this.statusBanner = `+ #${this.manager.items.length} (${item.sourceName})`;
1543
+ this.render();
1544
+ }
1545
+
1546
+ _qAction(type) {
1547
+ const it = this.manager.items[this.selectedQueueIdx];
1548
+ if (!it) return;
1549
+ if (type === 'resume') this.manager.resumeItem(it);
1550
+ else if (type === 'pause') this.manager.pauseItem(it);
1551
+ else if (type === 'cancel') this.manager.cancelItem(it);
1552
+ else if (type === 'remove') this.manager.removeItem(this.selectedQueueIdx);
1553
+ this.render();
1554
+ }
1555
+
1556
+ handleInput(data) {
1557
+ if (this.modal) {
1558
+ if (data === '\r' || data === '\n') {
1559
+ const cb = this.modal.onConfirm;
1560
+ const val = this.modal.value.trim();
1561
+ this.modal = null;
1562
+ cb(val);
1563
+ this.render();
1564
+ return;
1565
+ }
1566
+ if (data === '\x1b') {
1567
+ this.modal = null;
1568
+ this.render();
1569
+ return;
1570
+ }
1571
+ if (data === '\x15') {
1572
+ this.modal.value = '';
1573
+ this.modal.cursor = 0;
1574
+ this.render();
1575
+ return;
1576
+ }
1577
+ if (data === '\x7f' || data === '\b') {
1578
+ if (this.modal.value.length > 0) {
1579
+ this.modal.value = this.modal.value.slice(0, -1);
1580
+ }
1581
+ this.render();
1582
+ return;
1583
+ }
1584
+ if (!data.startsWith('\x1b')) {
1585
+ const clean = data.replace(/[\r\n\t]/g, '');
1586
+ this.modal.value += clean;
1587
+ this.render();
1588
+ }
1589
+ return;
1590
+ }
1591
+
1592
+ const mouseMatch = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(data);
1593
+ if (mouseMatch) {
1594
+ const btn = parseInt(mouseMatch[1], 10);
1595
+ const mx = parseInt(mouseMatch[2], 10) - 1;
1596
+ const my = parseInt(mouseMatch[3], 10) - 1;
1597
+ const isPress = mouseMatch[4] === 'M';
1598
+ if (isPress && btn === 0) {
1599
+ const hit = this.hitboxes.find((h) => h.y === my && mx >= h.x1 && mx <= h.x2);
1600
+ if (hit) {
1601
+ this.focusId = hit.id;
1602
+ hit.action();
1603
+ }
1604
+ }
1605
+ return;
1606
+ }
1607
+
1608
+ if (data === '\x03' || data === 'q' || data === 'Q' || data === '\x1b') {
1609
+ this.quit();
1610
+ return;
1611
+ }
1612
+ if (['1', '2', '3', '4', '5'].includes(data)) {
1613
+ this.activeTab = parseInt(data, 10);
1614
+ this.render();
1615
+ return;
1616
+ }
1617
+ if (data === 'u' || data === 'U') {
1618
+ this.activeTab = 1;
1619
+ this.openPrompt('Вставьте ссылку на трек или плейлист', this.opts.url, (val) => this._setUrl(val));
1620
+ return;
1621
+ }
1622
+ if (data === 'a' || data === 'A') {
1623
+ this._actionAddToQueue();
1624
+ return;
1625
+ }
1626
+ if (data === 'r' || data === 'R') return this._qAction('resume');
1627
+ if (data === 'p' || data === 'P') return this._qAction('pause');
1628
+ if (data === 'c' || data === 'C') return this._qAction('cancel');
1629
+ if (data === 'd' || data === 'D') return this._qAction('remove');
1630
+
1631
+ if (data === '\t' || data === '\x1b[B' || data === '\x1b[C') {
1632
+ if (this.focusOrder.length > 0) {
1633
+ const idx = Math.max(0, this.focusOrder.indexOf(this.focusId));
1634
+ this.focusId = this.focusOrder[(idx + 1) % this.focusOrder.length];
1635
+ this.render();
1636
+ }
1637
+ return;
1638
+ }
1639
+ if (data === '\x1b[Z' || data === '\x1b[A' || data === '\x1b[D') {
1640
+ if (this.focusOrder.length > 0) {
1641
+ const idx = Math.max(0, this.focusOrder.indexOf(this.focusId));
1642
+ this.focusId = this.focusOrder[(idx - 1 + this.focusOrder.length) % this.focusOrder.length];
1643
+ this.render();
1644
+ }
1645
+ return;
1646
+ }
1647
+ if (data === ' ' || data === '\r' || data === '\n') {
1648
+ const hit = this.hitboxes.find((h) => h.id === this.focusId);
1649
+ if (hit) hit.action();
1650
+ }
1651
+ }
1652
+ }
1653
+
1654
+ // ---------------------------------------------------------------------------
1655
+ // CLI Entrypoint & Exports for Testing
1656
+ // ---------------------------------------------------------------------------
1657
+
1658
+ function updatePackage() {
1659
+ console.log('Обновление EVA.md...');
1660
+ const result = process.platform === 'win32'
1661
+ ? spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'npm install --global --allow-scripts=ffmpeg-static,youtube-dl-exec eva-md@latest'], { stdio: 'inherit' })
1662
+ : spawnSync('npm', ['install', '--global', '--allow-scripts=ffmpeg-static,youtube-dl-exec', 'eva-md@latest'], { stdio: 'inherit' });
1663
+
1664
+ if (result.error) {
1665
+ console.error(`Не удалось запустить npm: ${result.error.message}`);
1666
+ process.exitCode = 1;
1667
+ } else if (result.status === 0) {
1668
+ console.log('EVA.md обновлена. Запустите команду eva снова.');
1669
+ } else {
1670
+ process.exitCode = result.status || 1;
1671
+ }
1672
+ }
1673
+
1674
+ function main() {
1675
+ const argv = process.argv.slice(2);
1676
+ if (argv[0] === '--update') {
1677
+ if (argv.length !== 1) {
1678
+ console.error('Использование: eva --update');
1679
+ process.exitCode = 1;
1680
+ return;
1681
+ }
1682
+ updatePackage();
1683
+ return;
1684
+ }
1685
+
1686
+ const isDemo = argv.includes('--demo');
1687
+ const isPrintCmd = argv.includes('--print-cmd');
1688
+ const urlArg = argv.find((a) => !a.startsWith('-')) || '';
1689
+
1690
+ const opts = loadSavedOptions();
1691
+ if (urlArg) opts.url = urlArg;
1692
+
1693
+ if (isPrintCmd) {
1694
+ console.log(buildCommandPreview(opts));
1695
+ return;
1696
+ }
1697
+
1698
+ const manager = new DownloadManager(isDemo);
1699
+
1700
+ if (isDemo) {
1701
+ if (!opts.url) opts.url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
1702
+ const samples = [
1703
+ { title: 'Валентин Стрыкало — Наше лето', src: 'SoundCloud', fmt: 'mp3', sz: '4.20 MB', prog: 68, st: 'Downloading' },
1704
+ { title: 'Lo-fi beats to relax/study to', src: 'YouTube Music', fmt: 'mp3', sz: '12.60 MB', prog: 25, st: 'Queued' },
1705
+ { title: 'The Weeknd - Blinding Lights', src: 'SoundCloud', fmt: 'mp3', sz: '8.70 MB', prog: 0, st: 'Queued' },
1706
+ { title: 'Imagine Dragons - Believer', src: 'Bandcamp', fmt: 'flac', sz: '24.30 MB', prog: 100, st: 'Done' },
1707
+ ];
1708
+ samples.forEach((s, i) => {
1709
+ manager.items.push({
1710
+ id: i + 1,
1711
+ url: opts.url,
1712
+ title: s.title,
1713
+ sourceName: s.src,
1714
+ fmt: s.fmt,
1715
+ outDir: opts.outputDir,
1716
+ destFile: '',
1717
+ sizeStr: s.sz,
1718
+ progress: s.prog,
1719
+ speedStr: '',
1720
+ etaStr: '',
1721
+ status: s.st,
1722
+ cmdArgs: buildYtdlpArgs(opts, opts.url),
1723
+ errorMsg: '',
1724
+ proc: null,
1725
+ simTimer: null,
1726
+ logLines: [`[download] ${s.prog}% of ${s.sz}`],
1727
+ });
1728
+ });
1729
+ manager.nextId = samples.length + 1;
1730
+ }
1731
+
1732
+ const app = new EvaNodeTUI(opts, manager);
1733
+ app.start();
1734
+ }
1735
+
1736
+ if (require.main === module) {
1737
+ main();
1738
+ }
1739
+
1740
+ module.exports = {
1741
+ createDefaultOptions,
1742
+ buildYtdlpArgs,
1743
+ buildCommandPreview,
1744
+ detectSource,
1745
+ DownloadManager,
1746
+ formatBytesToMB,
1747
+ wrapTextByWords,
1748
+ };