eva-md 0.6.1 → 0.7.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 (2) hide show
  1. package/eva.js +343 -185
  2. package/package.json +3 -3
package/eva.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * EVA.md v0.6.1 — EVA Media Downloader by .maxon
3
+ * EVA.md v0.7.3 — EVA Media Downloader by .maxon
4
4
  * Pure Node.js Interactive TUI (Zero external npm dependencies!)
5
5
  * Works just like Gemini CLI (`node eva.js` or `./eva.js`), with full mouse & keyboard support.
6
6
  *
@@ -22,12 +22,12 @@
22
22
  * --write-subs Download subtitles file
23
23
  * --embed-subs Embed subtitles into media container
24
24
  * --write-thumbnail Save thumbnail as a separate image file
25
- * File Naming & Archive (Именование файлов):
25
+ * File Naming (Именование файлов — во вкладке Settings):
26
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
27
  * Playlists (Плейлисты):
29
28
  * --playlist-items <range> Download specific items (e.g. "1-5")
30
29
  * --playlist-reverse Download playlist in reverse order
30
+ * --download-archive <file> Record downloaded track IDs in archive.txt
31
31
  * Network & Bypass (Сеть и обход блокировок — во вкладке Settings):
32
32
  * -4 Force IPv4 connection (fixes DNS issues)
33
33
  * --proxy <url> Use HTTP/SOCKS proxy (e.g. http://127.0.0.1:8080)
@@ -46,7 +46,7 @@ const { spawn, spawnSync } = require('child_process');
46
46
  // Constants & Configuration
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- const APP_VERSION = 'v0.6.1 (Node.js)';
49
+ const APP_VERSION = 'v0.7.3 (Node.js)';
50
50
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'eva_md', 'config.json');
51
51
  const MAX_LOG_LINES = 200;
52
52
  const MAX_LOG_LINE_LENGTH = 500;
@@ -83,14 +83,21 @@ const NAMING_PRESETS = [
83
83
  ];
84
84
 
85
85
  const ASCII_LOGO = [
86
- '███████╗██╗ ██╗ █████╗ ⠀⠨⣔⢺⢿⡿⠟⠛⠋⠩⠥⠄⠀⠌⠉⠝⠻⠿⣿⣿⣷⣿⣿⣳⡀',
87
- '██╔════╝██║ ██║██╔══██╗ ███╗ ███╗ ██████╗⠀⠀ ⢳⣭⢯⡆⠁⠠⠀⢀⣠⣤⠀⠀⠈⠀⠀⠈⡑⢬⢻⣿⣿⣿⣿⣧⡂⠌⠀ ',
88
- '█████╗ ╚██╗ ██╔╝███████║ ██╔████╔██╗██╔══██╗ ⠈⠲⣽⣻⣽⢦⠀⠠⠝⣿⣷⡀⠀⠶⠂⠀⣸⣿⣟⠕⢼⣿⣿⣿⢿⢦',
89
- '██╔══╝ ╚████╔╝ ██╔══██║ ██║╚██╔╝██║██║ ██║ ⢣⣼⣻⣿⣟⢇⠀⡜⠤⠻⢿⣄⣀⣀⠼⢟⡻⣃⠀⠘⣿⣿⠟',
90
- '███████╗ ╚██╔╝ ██║ ██║ ██╗██║ ╚═╝ ██║██████╔╝ ⠐⢮⡿⣽⣿⣿⣞⡵⢢⣌⣦⣕⣢⣬⣧⣺⣿⣿⣿⣿⠝',
91
- '╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ⠲⠕⠻⢿⣿⡻⣿⢯⢟⡿⢯⠟⠛⠻⠝⠋',
86
+ ' :::::::::: ::: ::: ::: ⡌⣔⣥⣔⣪⣯⣿⣿⣮⣳⣾⣻⣾⣽⢮⣷⢿⣦',
87
+ ' :+: :+: :+: :+: :+: ⠨⣔⢺⢿⡿⠟⠛⠋⠩⠥⠄⠀⠌⠉⠝⠻⠿⣿⣿⣷⣿⣿⣳',
88
+ ' +:+ +:+ +:+ +:+ +:+ ⢳⣭⢯⡆⠁⠠⠀⢀⣠⣤⠀⠀⠈⠀⠀⠈⡑⢬⢻⣿⣿⣿⣿⣧',
89
+ ' +#++:++# +#+ +:+ +#++:++#++: ⠈⠲⣽⣻⣽⢦⠀⠠⠝⣿⣷⡀⠀⠶⠂⠀⣸⣿⣟⠕⢼⣿⣿⣿⢿',
90
+ " +#+ +#+ +#+ +#+ +#+ ⢣⣼⣻⣿⣟⢇⠀⡜⠤⠻⢿⣄⣀⣀⠼⢟⡻⣃⠀⠘⣿⣿⠟",
91
+ ' #+# #+#+#+# #+# #+# ⠐⢮⡿⣽⣿⣿⣞⡵⢢⣌⣦⣕⣢⣬⣧⣺⣿⣿⣿⣿⠝',
92
+ "########## ## ### ### ⠲⠕⠻⢿⣿⡻⣿⢯⢟⡿⢯⠟⠛⠻⠝⠋",
92
93
  ];
93
94
 
95
+ const STRETCHED_ASCII_LOGO = ASCII_LOGO.map((line) => {
96
+ const chars = Array.from(line);
97
+ const width = Math.round(chars.length * 1.2);
98
+ return Array.from({ length: width }, (_, index) => chars[Math.floor(index / 1.2)]).join('');
99
+ });
100
+
94
101
  // ANSI Styling Helpers
95
102
  const CSI = '\x1b[';
96
103
  const ANSI = {
@@ -152,6 +159,16 @@ function formatBytesToMB(bytes) {
152
159
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
153
160
  }
154
161
 
162
+ function formatEta(eta) {
163
+ const parts = String(eta || '').split(':');
164
+ if (!parts.length || parts.length > 3 || parts.some((part) => !/^\d+$/.test(part))) return '00:00';
165
+
166
+ const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);
167
+ const minutes = Math.floor(seconds / 60);
168
+ const remainingSeconds = seconds % 60;
169
+ return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
170
+ }
171
+
155
172
  function sanitizeLogLine(line) {
156
173
  return String(line)
157
174
  .replace(ANSI_ESCAPE_RE, '')
@@ -344,15 +361,11 @@ function buildYtdlpArgs(opts, targetUrl = null) {
344
361
  if (opts.embedSubs) args.push('--embed-subs');
345
362
  if (opts.writeThumbnail) args.push('--write-thumbnail');
346
363
 
347
- // File Naming & Archive
364
+ // File Naming
348
365
  const outDir = resolveOutputDir(opts.outputDir);
349
366
  const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
350
367
  args.push('-o', path.join(outDir, tpl));
351
368
 
352
- if (opts.useDownloadArchive && opts.archiveFile.trim()) {
353
- args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
354
- }
355
-
356
369
  // Playlists
357
370
  if (opts.usePlaylistItems && opts.playlistItems.trim()) {
358
371
  args.push('--playlist-items', opts.playlistItems.trim());
@@ -360,6 +373,9 @@ function buildYtdlpArgs(opts, targetUrl = null) {
360
373
  if (opts.playlistReverse) {
361
374
  args.push('--playlist-reverse');
362
375
  }
376
+ if (opts.useDownloadArchive && opts.archiveFile.trim()) {
377
+ args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
378
+ }
363
379
 
364
380
  // Network & Bypass
365
381
  if (opts.forceIpv4) {
@@ -405,6 +421,71 @@ function detectSource(url, defaultId = 1) {
405
421
  return { id: found.id, name: found.name };
406
422
  }
407
423
 
424
+ function isVideoUrl(url) {
425
+ try {
426
+ const parsed = new URL(url);
427
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
428
+ const pathname = parsed.pathname.toLowerCase();
429
+
430
+ if (/\.(mp4|m4v|mkv|mov|webm|avi)$/i.test(pathname)) return true;
431
+ if (host === 'youtu.be') return pathname.length > 1;
432
+ if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
433
+ if (host === 'music.youtube.com') return false;
434
+ return (
435
+ (pathname === '/watch' && parsed.searchParams.has('v')) ||
436
+ /^\/(shorts|live|embed|v)\//.test(pathname)
437
+ );
438
+ }
439
+ if (host === 'vkvideo.ru') return /^\/video/.test(pathname);
440
+ if (host === 'vk.com') return /^\/video/.test(pathname) || pathname === '/video_ext.php';
441
+ return false;
442
+ } catch (_) {
443
+ return false;
444
+ }
445
+ }
446
+
447
+ function isAudioUrl(url) {
448
+ try {
449
+ const parsed = new URL(url);
450
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
451
+ const pathname = parsed.pathname.toLowerCase();
452
+
453
+ if (/\.(mp3|m4a|m4b|m4p|aac|flac|wav|ogg|opus|oga|wma)$/i.test(pathname)) return true;
454
+ if (host === 'music.youtube.com') return true;
455
+ if (
456
+ host === 'bandcamp.com' ||
457
+ host.endsWith('.bandcamp.com') ||
458
+ host === 'soundcloud.com' ||
459
+ host.endsWith('.soundcloud.com') ||
460
+ host === 'mixcloud.com' ||
461
+ host.endsWith('.mixcloud.com') ||
462
+ host === 'audiomack.com' ||
463
+ host.endsWith('.audiomack.com') ||
464
+ host === 'audius.co' ||
465
+ host.endsWith('.audius.co')
466
+ ) {
467
+ return true;
468
+ }
469
+ return host === 'vk.com' && /^\/audio/.test(pathname);
470
+ } catch (_) {
471
+ return false;
472
+ }
473
+ }
474
+
475
+ function applyVideoModeForUrl(opts, url) {
476
+ if (!isVideoUrl(url)) return false;
477
+ opts.bestVideoMerge = true;
478
+ opts.extractAudio = false;
479
+ return true;
480
+ }
481
+
482
+ function applyAudioModeForUrl(opts, url) {
483
+ if (!isAudioUrl(url)) return false;
484
+ opts.bestVideoMerge = false;
485
+ opts.extractAudio = true;
486
+ return true;
487
+ }
488
+
408
489
  function isYouTubePlaylistUrl(url) {
409
490
  try {
410
491
  const parsed = new URL(url);
@@ -1065,6 +1146,9 @@ class EvaNodeTUI {
1065
1146
  this.selectedLibraryFile = null;
1066
1147
  this.lastLibraryClick = null;
1067
1148
  this.statusBanner = '';
1149
+ this.cliMarqueeTimer = null;
1150
+ this.cliMarqueeText = '';
1151
+ this.cliMarqueeOffset = 0;
1068
1152
 
1069
1153
  this.modal = null;
1070
1154
  }
@@ -1087,10 +1171,51 @@ class EvaNodeTUI {
1087
1171
  }
1088
1172
 
1089
1173
  quit() {
1174
+ if (this.cliMarqueeTimer) {
1175
+ clearInterval(this.cliMarqueeTimer);
1176
+ this.cliMarqueeTimer = null;
1177
+ }
1090
1178
  process.stdout.write(ANSI.mouseOff + ANSI.showCursor + ANSI.altScreenOff + ANSI.reset + '\n');
1091
1179
  process.exit(0);
1092
1180
  }
1093
1181
 
1182
+ _stopCliMarquee() {
1183
+ if (this.cliMarqueeTimer) {
1184
+ clearInterval(this.cliMarqueeTimer);
1185
+ this.cliMarqueeTimer = null;
1186
+ }
1187
+ }
1188
+
1189
+ _renderCliPreview(scr, x, y, width, text) {
1190
+ if (text !== this.cliMarqueeText) {
1191
+ this.cliMarqueeText = text;
1192
+ this.cliMarqueeOffset = 0;
1193
+ }
1194
+
1195
+ if (text.length <= width) {
1196
+ this._stopCliMarquee();
1197
+ scr.putStr(y, x, text, ANSI.fgYellow);
1198
+ return;
1199
+ }
1200
+
1201
+ const gap = ' ';
1202
+ const stream = text + gap + text;
1203
+ const loopLength = text.length + gap.length;
1204
+ const start = this.cliMarqueeOffset % loopLength;
1205
+ scr.putStr(y, x, stream.slice(start, start + width).padEnd(width), ANSI.fgYellow);
1206
+
1207
+ if (!this.cliMarqueeTimer && !this.modal) {
1208
+ this.cliMarqueeTimer = setInterval(() => {
1209
+ if (this.activeTab !== 1 || this.modal) {
1210
+ this._stopCliMarquee();
1211
+ return;
1212
+ }
1213
+ this.cliMarqueeOffset = (this.cliMarqueeOffset + 1) % loopLength;
1214
+ this.render();
1215
+ }, 175);
1216
+ }
1217
+ }
1218
+
1094
1219
  registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
1095
1220
  this.focusOrder.push(id);
1096
1221
  const isFocused = this.focusId === id;
@@ -1144,6 +1269,10 @@ class EvaNodeTUI {
1144
1269
  scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
1145
1270
  const afterFolderIndex = folderLabelIndex + folderLabel.length;
1146
1271
  scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
1272
+ } else if (id === 'edit_outdir' && visibleLabel.startsWith('🗁')) {
1273
+ const iconLength = '🗁'.length;
1274
+ scr.putStr(y, textX, visibleLabel.slice(0, iconLength), style + ANSI.bold);
1275
+ scr.putStr(y, textX + iconLength, visibleLabel.slice(iconLength, editMarkerIndex), style);
1147
1276
  } else {
1148
1277
  scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1149
1278
  }
@@ -1186,6 +1315,8 @@ class EvaNodeTUI {
1186
1315
  // -------------------------------------------------------------------------
1187
1316
 
1188
1317
  render() {
1318
+ if (this.activeTab !== 1 || this.modal) this._stopCliMarquee();
1319
+
1189
1320
  if (this.selectedQueueItem) {
1190
1321
  const selectedIndex = this.manager.items.indexOf(this.selectedQueueItem);
1191
1322
  if (selectedIndex >= 0) this.selectedQueueIdx = selectedIndex;
@@ -1220,15 +1351,22 @@ class EvaNodeTUI {
1220
1351
  }
1221
1352
 
1222
1353
  _renderHeader(scr, cols) {
1223
- ASCII_LOGO.forEach((line, i) => {
1354
+ const logoRight = 2 + Math.max(...STRETCHED_ASCII_LOGO.map((line) => line.length));
1355
+ STRETCHED_ASCII_LOGO.forEach((line, i) => {
1224
1356
  scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
1225
1357
  });
1226
1358
  scr.putStr(7, 2, 'EVA MEDIA DOWNLOADER', ANSI.fgCyan);
1227
- scr.putStr(2, cols - APP_VERSION.length - 3, APP_VERSION, ANSI.fgWhite);
1359
+ const versionX = cols - APP_VERSION.length - 3;
1360
+ if (versionX > logoRight + 1) {
1361
+ scr.putStr(2, versionX, APP_VERSION, ANSI.fgWhite);
1362
+ }
1228
1363
  if (cols >= 90) {
1229
1364
  const tagline = 'Music. Videos. Your collection.';
1230
- scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
1231
- scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
1365
+ const taglineX = cols - tagline.length - 3;
1366
+ if (taglineX > logoRight + 1) {
1367
+ scr.putStr(5, taglineX, tagline, ANSI.fgWhite);
1368
+ scr.putStr(6, taglineX, '─'.repeat(tagline.length), ANSI.fgCyan);
1369
+ }
1232
1370
  }
1233
1371
  }
1234
1372
 
@@ -1292,17 +1430,10 @@ class EvaNodeTUI {
1292
1430
  const activeItems = items.filter((item) => item.status === 'Downloading');
1293
1431
  const doneCnt = items.filter((i) => i.status === 'Done').length;
1294
1432
 
1295
- let pct = 0;
1296
- if (activeItems.length > 0) {
1297
- const sum = activeItems.reduce((acc, i) => acc + (i.progress || 0), 0);
1298
- pct = Math.round(sum / activeItems.length);
1299
- } else if (items.length > 0 && doneCnt === items.length) {
1300
- pct = 100;
1301
- }
1302
-
1303
- const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
1433
+ const eta = formatEta(activeItems[0] && activeItems[0].etaStr);
1434
+ const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${eta}`;
1304
1435
  const queueStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
1305
- const queueStartX = cols - queueStatus.length;
1436
+ const queueStartX = cols - queueStatus.length - 1;
1306
1437
  scr.putStr(rows - 1, queueStartX, queueStatus, queueStyle);
1307
1438
 
1308
1439
  const statusX = queueStartX + queueStatus.length + 2;
@@ -1364,96 +1495,95 @@ class EvaNodeTUI {
1364
1495
  y += 7;
1365
1496
 
1366
1497
  // --- Box 2: Download Options & yt-dlp Flags ---
1367
- const box2H = 11;
1498
+ const box2H = 12;
1368
1499
  scr.clipRight = previousClipRight;
1369
1500
  scr.drawBox(y, x, box2H, w, 'Download Options Flags');
1370
1501
  scr.clipRight = x + w - 2;
1371
1502
  const c1 = x + 2;
1372
1503
 
1373
- // Row 1: Audio (-x, metadata, thumbnail)
1374
- scr.putStr(y + 1, c1, 'АУДИО:', ANSI.fgCyan + ANSI.bold);
1375
- let ax = c1 + 7;
1376
- ax =
1504
+ const renderAudioFormat = (row) => {
1505
+ scr.putStr(row, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
1506
+ let fx = c1 + 8;
1507
+ AUDIO_FORMATS.forEach((fmt) => {
1508
+ fx =
1509
+ this.registerWidget(
1510
+ scr,
1511
+ `fmt_${fmt}`,
1512
+ row,
1513
+ fx,
1514
+ fmt,
1515
+ () => {
1516
+ this.opts.audioFormat = fmt;
1517
+ this.opts.extractAudio = true;
1518
+ this.opts.bestVideoMerge = false;
1519
+ this.statusBanner = `Формат: --audio-format ${fmt}`;
1520
+ this.render();
1521
+ },
1522
+ { radio: this.opts.audioFormat === fmt }
1523
+ ) + 2;
1524
+ });
1525
+ };
1526
+
1527
+ const renderAudioQuality = (row) => {
1528
+ scr.putStr(row, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
1529
+ let qx = c1 + 9;
1530
+ AUDIO_QUALITIES.forEach((q) => {
1531
+ qx =
1532
+ this.registerWidget(
1533
+ scr,
1534
+ `qual_${q.val}`,
1535
+ row,
1536
+ qx,
1537
+ q.label,
1538
+ () => {
1539
+ this.opts.audioQuality = q.val;
1540
+ this.statusBanner = `Качество: --audio-quality ${q.val}`;
1541
+ this.render();
1542
+ },
1543
+ { radio: this.opts.audioQuality === q.val }
1544
+ ) + 2;
1545
+ });
1546
+ };
1547
+
1548
+ {
1549
+ const videoX = c1 + Math.floor((w - 4) / 2);
1550
+ const videoRight = x + w - 2;
1551
+
1552
+ scr.putStr(y + 1, c1, 'АУДИО:', ANSI.fgCyan + ANSI.bold);
1377
1553
  this.registerWidget(
1378
1554
  scr,
1379
1555
  'flag_x',
1380
1556
  y + 1,
1381
- ax,
1557
+ c1 + 7,
1382
1558
  'Только аудио',
1383
1559
  () => this._toggle('extractAudio'),
1384
- { checked: this.opts.extractAudio }
1385
- ) + 2;
1386
- ax =
1560
+ { checked: this.opts.extractAudio, maxWidth: videoX - c1 - 8 }
1561
+ );
1562
+ this.registerWidget(
1563
+ scr,
1564
+ 'flag_thumb',
1565
+ y + 2,
1566
+ c1 + 7,
1567
+ 'Встроить обложку',
1568
+ () => this._toggle('embedThumbnail'),
1569
+ { checked: this.opts.embedThumbnail, maxWidth: videoX - c1 - 8 }
1570
+ );
1387
1571
  this.registerWidget(
1388
1572
  scr,
1389
1573
  'flag_meta',
1390
- y + 1,
1391
- ax,
1574
+ y + 3,
1575
+ c1 + 7,
1392
1576
  'Встроить метаданные',
1393
1577
  () => this._toggle('embedMetadata'),
1394
- { checked: this.opts.embedMetadata }
1395
- ) + 2;
1396
- this.registerWidget(
1397
- scr,
1398
- 'flag_thumb',
1399
- y + 1,
1400
- ax,
1401
- 'Встроить обложку',
1402
- () => this._toggle('embedThumbnail'),
1403
- { checked: this.opts.embedThumbnail, maxWidth: x + w - ax - 2 }
1404
- );
1405
-
1406
- // Row 2: Format (--audio-format)
1407
- scr.putStr(y + 2, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
1408
- let fx = c1 + 8;
1409
- AUDIO_FORMATS.forEach((fmt) => {
1410
- fx =
1411
- this.registerWidget(
1412
- scr,
1413
- `fmt_${fmt}`,
1414
- y + 2,
1415
- fx,
1416
- fmt,
1417
- () => {
1418
- this.opts.audioFormat = fmt;
1419
- this.opts.extractAudio = true;
1420
- this.opts.bestVideoMerge = false;
1421
- this.statusBanner = `Формат: --audio-format ${fmt}`;
1422
- this.render();
1423
- },
1424
- { radio: this.opts.audioFormat === fmt }
1425
- ) + 2;
1426
- });
1427
-
1428
- // Row 3: Quality (--audio-quality)
1429
- scr.putStr(y + 3, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
1430
- let qx = c1 + 9;
1431
- AUDIO_QUALITIES.forEach((q) => {
1432
- qx =
1433
- this.registerWidget(
1434
- scr,
1435
- `qual_${q.val}`,
1436
- y + 3,
1437
- qx,
1438
- q.label,
1439
- () => {
1440
- this.opts.audioQuality = q.val;
1441
- this.statusBanner = `Качество: --audio-quality ${q.val}`;
1442
- this.render();
1443
- },
1444
- { radio: this.opts.audioQuality === q.val }
1445
- ) + 2;
1446
- });
1578
+ { checked: this.opts.embedMetadata, maxWidth: videoX - c1 - 8 }
1579
+ );
1447
1580
 
1448
- // Row 4: Video quality and subtitle file
1449
- scr.putStr(y + 4, c1, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
1450
- let vx = c1 + 7;
1451
- vx =
1581
+ scr.putStr(y + 1, videoX, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
1452
1582
  this.registerWidget(
1453
1583
  scr,
1454
1584
  'flag_bvbab',
1455
- y + 4,
1456
- vx,
1585
+ y + 1,
1586
+ videoX + 7,
1457
1587
  'Видео+Аудио',
1458
1588
  () => {
1459
1589
  this.opts.bestVideoMerge = !this.opts.bestVideoMerge;
@@ -1461,123 +1591,116 @@ class EvaNodeTUI {
1461
1591
  this.statusBanner = this.opts.bestVideoMerge ? 'Режим видео -f "bv*+ba/b"' : 'Режим аудио -x';
1462
1592
  this.render();
1463
1593
  },
1464
- { checked: this.opts.bestVideoMerge }
1465
- ) + 2;
1466
- vx =
1467
- this.registerWidget(scr, 'flag_wsubs', y + 4, vx, 'Файл субтитров', () => this._toggle('writeSubs'), {
1468
- checked: this.opts.writeSubs,
1469
- }) + 2;
1470
- let mediaOptionsX =
1471
- this.registerWidget(scr, 'flag_esubs', y + 5, c1 + 7, 'Встроить субтитры', () => this._toggle('embedSubs'), {
1472
- checked: this.opts.embedSubs,
1473
- }) + 2;
1474
- this.registerWidget(scr, 'flag_wthumb', y + 5, mediaOptionsX, 'Скачать обложку', () => this._toggle('writeThumbnail'), {
1475
- checked: this.opts.writeThumbnail,
1476
- maxWidth: x + w - mediaOptionsX - 2,
1477
- });
1478
-
1479
- // Row 6: Naming & Archive (-o & --download-archive)
1480
- scr.putStr(y + 6, c1, 'ФАЙЛЫ:', ANSI.fgCyan + ANSI.bold);
1481
- const rowRight = x + w - 2;
1482
- const templateX = c1 + 9;
1483
- const rowWidth = rowRight - templateX + 1;
1484
- const templateMaxWidth = Math.max(4, 4 + Math.floor(Math.max(0, rowWidth - 24) / 2));
1485
- const templateEnd = this.registerWidget(
1486
- scr,
1487
- 'flag_otpl',
1488
- y + 6,
1489
- templateX,
1490
- `-o "${this.opts.outputTemplate}"`,
1491
- () => this._toggle('usePlaylistTemplate'),
1492
- {
1493
- checked: this.opts.usePlaylistTemplate,
1494
- maxWidth: templateMaxWidth,
1495
- }
1496
- );
1497
- const templateEditEnd = this.registerWidget(scr, 'edit_otpl', y + 6, templateEnd + 1, '[✎ ]', () => {
1498
- return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1499
- this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1500
- });
1501
- }, { maxWidth: 6 });
1594
+ { checked: this.opts.bestVideoMerge, maxWidth: videoRight - videoX - 6 }
1595
+ );
1596
+ this.registerWidget(
1597
+ scr,
1598
+ 'flag_wthumb',
1599
+ y + 2,
1600
+ videoX + 7,
1601
+ 'Скачать обложку',
1602
+ () => this._toggle('writeThumbnail'),
1603
+ { checked: this.opts.writeThumbnail, maxWidth: videoRight - videoX - 6 }
1604
+ );
1605
+ this.registerWidget(
1606
+ scr,
1607
+ 'flag_wsubs',
1608
+ y + 3,
1609
+ videoX + 7,
1610
+ 'Файл субтитров',
1611
+ () => this._toggle('writeSubs'),
1612
+ { checked: this.opts.writeSubs, maxWidth: videoRight - videoX - 6 }
1613
+ );
1614
+ this.registerWidget(
1615
+ scr,
1616
+ 'flag_esubs',
1617
+ y + 4,
1618
+ videoX + 7,
1619
+ 'Встроить субтитры',
1620
+ () => this._toggle('embedSubs'),
1621
+ { checked: this.opts.embedSubs, maxWidth: videoRight - videoX - 6 }
1622
+ );
1502
1623
 
1503
- const archiveX = templateEditEnd + 2;
1504
- const archiveMaxWidth = Math.max(4, rowRight - archiveX - '[✎ ]'.length);
1505
- const archiveEnd = this.registerWidget(
1506
- scr,
1507
- 'flag_archive',
1508
- y + 6,
1509
- archiveX,
1510
- `История загрузки ${this.opts.archiveFile}`,
1511
- () => this._toggle('useDownloadArchive'),
1512
- {
1513
- checked: this.opts.useDownloadArchive,
1514
- maxWidth: archiveMaxWidth,
1515
- }
1516
- );
1517
- this.registerWidget(scr, 'edit_archive', y + 6, archiveEnd + 1, '[✎ ]', () => {
1518
- return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1519
- this.opts.archiveFile = v || 'archive.txt';
1520
- });
1521
- }, { maxWidth: 6 });
1624
+ renderAudioQuality(y + 5);
1625
+ renderAudioFormat(y + 6);
1626
+ }
1522
1627
 
1523
- // Row 7: Playlists (--playlist-items & --playlist-reverse)
1524
- scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
1628
+ // Row 8: Playlists (--playlist-items, --playlist-reverse & --download-archive)
1629
+ scr.putStr(y + 8, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
1630
+ const rowRight = x + w - 2;
1525
1631
  let px =
1526
1632
  this.registerWidget(
1527
1633
  scr,
1528
1634
  'flag_plitems',
1529
- y + 7,
1530
- c1 + 9,
1531
- `Выбрать файлы ${this.opts.playlistItems}`,
1635
+ y + 8,
1636
+ c1 + 10,
1637
+ `Скачать № ${this.opts.playlistItems}`,
1532
1638
  () => this._toggle('usePlaylistItems'),
1533
- { checked: this.opts.usePlaylistItems }
1639
+ { checked: this.opts.usePlaylistItems, maxWidth: 18 }
1534
1640
  ) + 1;
1535
1641
  px =
1536
- this.registerWidget(scr, 'edit_plitems', y + 7, px, '[✎ ]', () =>
1642
+ this.registerWidget(scr, 'edit_plitems', y + 8, px, '[✎ ]', () =>
1537
1643
  this.openPrompt('Номера треков (--playlist-items, напр. 1-5)', this.opts.playlistItems, (v) => {
1538
1644
  this.opts.playlistItems = v || '1-5';
1539
1645
  })
1540
- ) + 3;
1646
+ ) + 2;
1541
1647
 
1542
- this.registerWidget(
1648
+ px =
1649
+ this.registerWidget(
1650
+ scr,
1651
+ 'flag_plrev',
1652
+ y + 8,
1653
+ px,
1654
+ 'С конца',
1655
+ () => this._toggle('playlistReverse'),
1656
+ { checked: this.opts.playlistReverse, maxWidth: Math.max(5, rowRight - px - 1) }
1657
+ ) + 2;
1658
+
1659
+ const archiveEditWidth = '[✎ ]'.length;
1660
+ const archiveFlagWidth = Math.max(5, rowRight - px - archiveEditWidth);
1661
+ const archiveEnd = this.registerWidget(
1543
1662
  scr,
1544
- 'flag_plrev',
1545
- y + 7,
1663
+ 'flag_archive',
1664
+ y + 8,
1546
1665
  px,
1547
- 'Загрузить плейлист с конца',
1548
- () => this._toggle('playlistReverse'),
1549
- { checked: this.opts.playlistReverse, maxWidth: x + w - px - 2 }
1666
+ `История ${this.opts.archiveFile}`,
1667
+ () => this._toggle('useDownloadArchive'),
1668
+ { checked: this.opts.useDownloadArchive, maxWidth: archiveFlagWidth }
1550
1669
  );
1670
+ if (archiveEnd + 1 <= rowRight) {
1671
+ this.registerWidget(scr, 'edit_archive', y + 8, archiveEnd + 1, '[✎ ]', () => {
1672
+ return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1673
+ this.opts.archiveFile = v || 'archive.txt';
1674
+ });
1675
+ }, { maxWidth: Math.min(6, rowRight - archiveEnd) });
1676
+ }
1551
1677
 
1552
- // Row 9: Output directory & Add to Queue CTA button
1678
+ // Keep the output controls on the last inner row of the options box.
1679
+ const outputControlsRow = y + box2H - 2;
1553
1680
  const maxDirLen = Math.max(16, w - 48);
1554
1681
  const shortDir =
1555
1682
  this.opts.outputDir.length > maxDirLen
1556
1683
  ? '…' + this.opts.outputDir.slice(-(maxDirLen - 1))
1557
1684
  : this.opts.outputDir;
1558
1685
  let dx =
1559
- this.registerWidget(scr, 'edit_outdir', y + 9, c1, `📁: ${shortDir} [✎ ]`, () =>
1686
+ this.registerWidget(scr, 'edit_outdir', outputControlsRow, c1, `📁: ${shortDir} [✎ ]`, () =>
1560
1687
  this.openPrompt('Папка сохранения', this.opts.outputDir, (v) => {
1561
1688
  this.opts.outputDir = v || path.join(os.homedir(), 'Downloads');
1562
1689
  })
1563
1690
  ) + 3;
1564
1691
 
1565
- const ctaLabel = 'Скачать (Enter / a)';
1692
+ const ctaLabel = '[A] Скачать';
1566
1693
  const ctaX = Math.max(dx, x + w - ctaLabel.length - 3);
1567
- this.registerWidget(scr, 'btn_add_queue', y + 9, ctaX, ctaLabel, () => this._actionAddToQueue(), {
1694
+ this.registerWidget(scr, 'btn_add_queue', outputControlsRow, ctaX, ctaLabel, () => this._actionAddToQueue(), {
1568
1695
  isCta: true,
1569
1696
  });
1570
1697
 
1571
1698
  y += box2H;
1572
1699
  scr.clipRight = previousClipRight;
1573
1700
 
1574
- // Word-wrapped CLI Preview Lines (never overflows the window width)
1701
+ // Scroll long command previews horizontally so they remain readable in compact windows.
1575
1702
  const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
1576
- const wrappedCli = wrapTextByWords(fullCliText, w - 2);
1577
- const availCliRows = Math.max(1, rows - 2 - y);
1578
- wrappedCli.slice(0, availCliRows).forEach((line, idx) => {
1579
- scr.putStr(y + idx, x + 1, line, ANSI.fgYellow);
1580
- });
1703
+ this._renderCliPreview(scr, x + 1, y, w - 2, fullCliText);
1581
1704
  }
1582
1705
 
1583
1706
  // -------------------------------------------------------------------------
@@ -1914,7 +2037,28 @@ class EvaNodeTUI {
1914
2037
  this.render();
1915
2038
  }, { isCta: true });
1916
2039
 
1917
- scr.putStr(15, c1, 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ (-o):', ANSI.fgCyan + ANSI.bold);
2040
+ const namingHeading = 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ';
2041
+ scr.putStr(15, c1, namingHeading, ANSI.fgCyan + ANSI.bold);
2042
+ const namingTemplateX = c1 + namingHeading.length + 1;
2043
+ const namingRight = x + w - 2;
2044
+ const namingTemplateEnd = this.registerWidget(
2045
+ scr,
2046
+ 'flag_otpl',
2047
+ 15,
2048
+ namingTemplateX,
2049
+ `-o "${this.opts.outputTemplate}"`,
2050
+ () => this._toggle('usePlaylistTemplate'),
2051
+ {
2052
+ checked: this.opts.usePlaylistTemplate,
2053
+ maxWidth: Math.max(5, namingRight - namingTemplateX - '[✎ ]'.length),
2054
+ }
2055
+ );
2056
+ this.registerWidget(scr, 'edit_otpl', 15, namingTemplateEnd + 1, '[✎ ]', () => {
2057
+ return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
2058
+ this.opts.outputTemplate = v || '%(title)s.%(ext)s';
2059
+ });
2060
+ }, { maxWidth: 6 });
2061
+
1918
2062
  NAMING_PRESETS.forEach((p, idx) => {
1919
2063
  this.registerWidget(
1920
2064
  scr,
@@ -1992,8 +2136,8 @@ class EvaNodeTUI {
1992
2136
  'Интегрированные флаги yt-dlp:',
1993
2137
  ' • Аудио: -x, --audio-format (mp3/flac/m4a/wav), --audio-quality 0, --embed-metadata, --embed-thumbnail',
1994
2138
  ' • Видео: -f "bv*+ba/b", --write-subs, --embed-subs, --write-thumbnail, --merge-output-format mp4',
1995
- ' • Файлы: -o "%(playlist_index)s. %(title)s.%(ext)s", --download-archive archive.txt',
1996
- ' • Плейлисты: --playlist-items 1-5, --playlist-reverse',
2139
+ ' • Файлы (вкладка Settings): -o "%(playlist_index)s. %(title)s.%(ext)s"',
2140
+ ' • Плейлисты: --playlist-items 1-5, --playlist-reverse, --download-archive archive.txt',
1997
2141
  ' • Сеть (вкладка Settings): -4, --proxy http://127.0.0.1:8080, --cookies-from-browser chrome',
1998
2142
  ` `,
1999
2143
  ` `,
@@ -2026,11 +2170,17 @@ class EvaNodeTUI {
2026
2170
  _setUrl(val) {
2027
2171
  this.opts.url = val;
2028
2172
  if (val) {
2173
+ const videoMode = applyVideoModeForUrl(this.opts, val);
2174
+ const audioMode = !videoMode && applyAudioModeForUrl(this.opts, val);
2029
2175
  const detected = detectSource(val, this.opts.sourceId);
2030
2176
  if (this.opts.sourceId !== 1) {
2031
2177
  this.opts.sourceId = detected.id;
2032
2178
  }
2033
- this.statusBanner = `URL (${detected.name})`;
2179
+ this.statusBanner = videoMode
2180
+ ? `Видео+Аудио (${detected.name})`
2181
+ : audioMode
2182
+ ? `Только аудио (${detected.name})`
2183
+ : `URL (${detected.name})`;
2034
2184
  }
2035
2185
  this.render();
2036
2186
  }
@@ -2228,7 +2378,10 @@ function main() {
2228
2378
  const urlArg = argv.find((a) => !a.startsWith('-')) || '';
2229
2379
 
2230
2380
  const opts = loadSavedOptions();
2231
- if (urlArg) opts.url = urlArg;
2381
+ if (urlArg) {
2382
+ opts.url = urlArg;
2383
+ if (!applyVideoModeForUrl(opts, urlArg)) applyAudioModeForUrl(opts, urlArg);
2384
+ }
2232
2385
 
2233
2386
  if (isPrintCmd) {
2234
2387
  console.log(buildCommandPreview(opts));
@@ -2283,11 +2436,16 @@ module.exports = {
2283
2436
  buildPlaylistDiscoveryArgs,
2284
2437
  buildCommandPreview,
2285
2438
  detectSource,
2439
+ isVideoUrl,
2440
+ isAudioUrl,
2441
+ applyVideoModeForUrl,
2442
+ applyAudioModeForUrl,
2286
2443
  isYouTubePlaylistUrl,
2287
2444
  playlistEntryUrl,
2288
2445
  ScreenBuffer,
2289
2446
  DownloadManager,
2290
2447
  formatBytesToMB,
2448
+ formatEta,
2291
2449
  fitTextToCellWidth,
2292
2450
  wrapTextByWords,
2293
2451
  };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "eva-md",
3
- "version": "0.6.1",
4
- "description": "EVA Media Downloader CLI",
3
+ "version": "0.7.3",
4
+ "description": "Eva Media Downloader CLI",
5
5
  "main": "eva.js",
6
6
  "bin": {
7
- "eva": "./eva.js"
7
+ "eva": "eva.js"
8
8
  },
9
9
  "files": [
10
10
  "eva.js",