eva-md 0.3.3 → 0.4.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 +468 -67
  2. package/package.json +1 -1
package/eva.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * EVA.md v0.3.2 — EVA Media Downloader by .maxon
3
+ * EVA.md v0.3.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
  *
@@ -46,8 +46,12 @@ const { spawn, spawnSync } = require('child_process');
46
46
  // Constants & Configuration
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- const APP_VERSION = 'v0.3.2 (Node.js)';
49
+ const APP_VERSION = 'v0.4.3 (Node.js)';
50
50
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'eva_md', 'config.json');
51
+ const MAX_LOG_LINES = 200;
52
+ const MAX_LOG_LINE_LENGTH = 500;
53
+ const MAX_DOWNLOAD_RETRIES = 3;
54
+ const ANSI_ESCAPE_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
51
55
 
52
56
  // Strictly 1-cell narrow glyphs (CP437/WGL4 safe) so Windows Terminal / PowerShell NEVER shifts borders!
53
57
  const SOURCES = [
@@ -144,6 +148,79 @@ function formatBytesToMB(bytes) {
144
148
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
145
149
  }
146
150
 
151
+ function sanitizeLogLine(line) {
152
+ return String(line)
153
+ .replace(ANSI_ESCAPE_RE, '')
154
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
155
+ .trim()
156
+ .slice(0, MAX_LOG_LINE_LENGTH);
157
+ }
158
+
159
+ function fitTextToCellWidth(text, width) {
160
+ const chars = [];
161
+ let totalCells = 0;
162
+ for (const char of String(text)) {
163
+ const code = char.codePointAt(0);
164
+ const zeroWidth =
165
+ /\p{Mark}/u.test(char) ||
166
+ code === 0x200d ||
167
+ (code >= 0xfe00 && code <= 0xfe0f) ||
168
+ (code >= 0xe0100 && code <= 0xe01ef);
169
+ if (zeroWidth) continue;
170
+ const isWide =
171
+ (code >= 0x1100 && code <= 0x11ff) ||
172
+ (code >= 0x2e80 && code <= 0xa4cf) ||
173
+ (code >= 0x20000 && code <= 0x3fffd) ||
174
+ (code >= 0xac00 && code <= 0xd7af) ||
175
+ (code >= 0xf900 && code <= 0xfaff) ||
176
+ (code >= 0xfe10 && code <= 0xfe6f) ||
177
+ (code >= 0xff01 && code <= 0xff60) ||
178
+ (code >= 0xffe0 && code <= 0xffe6) ||
179
+ (code >= 0x1f000 && code <= 0x1faff);
180
+ chars.push({ value: isWide || code > 0xffff ? '?' : char, width: 1 });
181
+ totalCells += 1;
182
+ }
183
+
184
+ const clipped = totalCells > width;
185
+ const contentWidth = clipped ? Math.max(0, width - 1) : width;
186
+ let result = '';
187
+ let cells = 0;
188
+ for (const char of chars) {
189
+ if (cells + char.width > contentWidth) break;
190
+ result += char.value;
191
+ cells += char.width;
192
+ }
193
+ if (clipped && width > 0) {
194
+ result += '…';
195
+ cells += 1;
196
+ }
197
+ return result + ' '.repeat(Math.max(0, width - cells));
198
+ }
199
+
200
+ function setWindowsProcessTreeState(rootPid, action) {
201
+ const script = [
202
+ `$rootId = ${rootPid}`,
203
+ "$operation = '" + action + "'",
204
+ "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class EvaProcessControl { [DllImport(\"kernel32.dll\", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint processId); [DllImport(\"kernel32.dll\")] public static extern bool CloseHandle(IntPtr handle); [DllImport(\"ntdll.dll\")] public static extern int NtSuspendProcess(IntPtr handle); [DllImport(\"ntdll.dll\")] public static extern int NtResumeProcess(IntPtr handle); }'",
205
+ '$processes = Get-CimInstance Win32_Process',
206
+ '$targets = New-Object System.Collections.Generic.List[int]',
207
+ '$targets.Add([int]$rootId)',
208
+ 'for ($i = 0; $i -lt $targets.Count; $i++) { foreach ($entry in $processes) { $childId = [int]$entry.ProcessId; if ([int]$entry.ParentProcessId -eq $targets[$i] -and -not $targets.Contains($childId)) { $targets.Add($childId) } } }',
209
+ '$ordered = @($targets.ToArray())',
210
+ 'if ($operation -eq "pause") { [array]::Reverse($ordered) }',
211
+ 'foreach ($targetId in $ordered) { $handle = [EvaProcessControl]::OpenProcess(0x0800, $false, [uint32]$targetId); if ($handle -eq [IntPtr]::Zero) { continue }; try { if ($operation -eq "pause") { $result = [EvaProcessControl]::NtSuspendProcess($handle) } else { $result = [EvaProcessControl]::NtResumeProcess($handle) }; if ($result -ne 0) { throw "Process control failed for PID $targetId (NTSTATUS $result)" } } finally { [void][EvaProcessControl]::CloseHandle($handle) } }',
212
+ ].join('; ');
213
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
214
+ encoding: 'utf8',
215
+ windowsHide: true,
216
+ timeout: 10000,
217
+ });
218
+ if (result.error) throw result.error;
219
+ if (result.status !== 0) {
220
+ throw new Error((result.stderr || result.stdout || `PowerShell exited with code ${result.status}`).trim());
221
+ }
222
+ }
223
+
147
224
  function wrapTextByWords(text, maxWidth) {
148
225
  if (maxWidth <= 10) return [text.slice(0, Math.max(1, maxWidth))];
149
226
  const words = String(text).split(' ');
@@ -324,6 +401,40 @@ function detectSource(url, defaultId = 1) {
324
401
  return { id: found.id, name: found.name };
325
402
  }
326
403
 
404
+ function isYouTubePlaylistUrl(url) {
405
+ try {
406
+ const parsed = new URL(url);
407
+ return /(^|\.)youtube\.com$/i.test(parsed.hostname) && Boolean(parsed.searchParams.get('list'));
408
+ } catch (_) {
409
+ return false;
410
+ }
411
+ }
412
+
413
+ function buildPlaylistDiscoveryArgs(opts, url) {
414
+ const args = ['--flat-playlist', '--dump-json', '--skip-download', '--no-warnings', '--encoding', 'utf-8'];
415
+ if (opts.usePlaylistItems && opts.playlistItems.trim()) {
416
+ args.push('--playlist-items', opts.playlistItems.trim());
417
+ }
418
+ if (opts.playlistReverse) args.push('--playlist-reverse');
419
+ if (opts.forceIpv4) args.push('-4');
420
+ if (opts.useProxy && opts.proxyUrl.trim()) args.push('--proxy', opts.proxyUrl.trim());
421
+ if (opts.useCookiesBrowser && opts.cookiesBrowser && opts.cookiesBrowser !== 'none') {
422
+ args.push('--cookies-from-browser', opts.cookiesBrowser);
423
+ }
424
+ args.push(url);
425
+ return args;
426
+ }
427
+
428
+ function playlistEntryUrl(entry, sourceUrl) {
429
+ for (const candidate of [entry.webpage_url, entry.original_url, entry.url]) {
430
+ if (typeof candidate === 'string' && /^https?:\/\//i.test(candidate)) return candidate;
431
+ }
432
+ if (entry.id && isYouTubePlaylistUrl(sourceUrl)) {
433
+ return `https://www.youtube.com/watch?v=${encodeURIComponent(entry.id)}`;
434
+ }
435
+ return '';
436
+ }
437
+
327
438
  // ---------------------------------------------------------------------------
328
439
  // Download Manager (Subprocess Spawner & Real-Time Progress Parser)
329
440
  // ---------------------------------------------------------------------------
@@ -345,16 +456,43 @@ class DownloadManager {
345
456
 
346
457
  addTask(opts, startImmediately = true) {
347
458
  const url = opts.url.trim();
459
+ const taskOpts = { ...opts };
460
+ if (isYouTubePlaylistUrl(url)) {
461
+ const item = this._createQueueItem(taskOpts, url, 'Получение списка плейлиста…');
462
+ item.status = 'Preparing';
463
+ item.isPlaylistExpansion = true;
464
+ item.startImmediately = startImmediately;
465
+ item.cmdArgs = buildPlaylistDiscoveryArgs(taskOpts, url);
466
+ this.items.push(item);
467
+ this._expandPlaylist(item, taskOpts);
468
+ this.onUpdate();
469
+ return item;
470
+ }
471
+
472
+ const item = this._createQueueItem(taskOpts, url);
473
+ item.startImmediately = startImmediately;
474
+ this.items.push(item);
475
+ if (startImmediately) this._startNextQueued();
476
+ this.onUpdate();
477
+ return item;
478
+ }
479
+
480
+ _createQueueItem(opts, url, title = null, entry = null, entryIndex = 0) {
348
481
  const src = detectSource(url, opts.sourceId);
349
482
  const fmt = opts.bestVideoMerge && !opts.extractAudio ? 'mp4/mkv' : opts.audioFormat;
350
483
  const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
351
- const cmdArgs = buildYtdlpArgs(opts, url);
484
+ const itemOpts = { ...opts, usePlaylistItems: false, playlistReverse: false };
485
+ if (entry && itemOpts.usePlaylistTemplate) {
486
+ const playlistIndex = String(entry.playlist_index || entryIndex + 1).padStart(2, '0');
487
+ itemOpts.outputTemplate = itemOpts.outputTemplate.replace(/%\(\s*playlist_index\s*\)s/g, playlistIndex);
488
+ }
489
+ const cmdArgs = entry ? buildYtdlpArgs(itemOpts, url) : buildYtdlpArgs(opts, url);
352
490
 
353
491
  let shortTitle = url.replace(/^https?:\/\//, '');
354
- const item = {
492
+ return {
355
493
  id: this.nextId++,
356
494
  url,
357
- title: `[${src.name}] ${shortTitle.slice(0, 44)}`,
495
+ title: entry && entry.title ? String(entry.title) : title || `[${src.name}] ${shortTitle.slice(0, 44)}`,
358
496
  sourceName: src.name,
359
497
  fmt,
360
498
  outDir,
@@ -364,18 +502,144 @@ class DownloadManager {
364
502
  speedStr: '',
365
503
  etaStr: '',
366
504
  status: 'Queued',
505
+ retryCount: 0,
367
506
  cmdArgs,
368
507
  errorMsg: '',
369
508
  proc: null,
370
509
  simTimer: null,
510
+ killTimer: null,
371
511
  logLines: [],
372
512
  };
373
- this.items.push(item);
374
- if (startImmediately) {
375
- this.startItem(item);
513
+ }
514
+
515
+ _expandPlaylist(placeholder, opts) {
516
+ if (!this.ytdlpPath) {
517
+ placeholder.status = 'Error';
518
+ placeholder.errorMsg = 'yt-dlp not found in PATH';
519
+ placeholder.logLines.push(placeholder.errorMsg);
520
+ this.onUpdate();
521
+ return;
522
+ }
523
+
524
+ const proc = spawn(this.ytdlpPath, placeholder.cmdArgs, {
525
+ stdio: ['ignore', 'pipe', 'pipe'],
526
+ env: Object.assign({}, process.env, {
527
+ PYTHONIOENCODING: 'utf-8',
528
+ PYTHONUTF8: '1',
529
+ LANG: 'en_US.UTF-8',
530
+ LC_ALL: 'en_US.UTF-8',
531
+ }),
532
+ detached: process.platform !== 'win32',
533
+ });
534
+ placeholder.proc = proc;
535
+
536
+ const stdoutDecoder = new StringDecoder('utf8');
537
+ const stderrDecoder = new StringDecoder('utf8');
538
+ let stdoutBuffer = '';
539
+ let stderrBuffer = '';
540
+ const entries = [];
541
+ let processError = null;
542
+
543
+ const parseEntryLine = (line) => {
544
+ if (!line.trim()) return;
545
+ try {
546
+ entries.push(JSON.parse(line));
547
+ } catch (error) {
548
+ processError = `Could not parse playlist item: ${error.message}`;
549
+ }
550
+ };
551
+
552
+ proc.stdout.on('data', (chunk) => {
553
+ stdoutBuffer += stdoutDecoder.write(chunk);
554
+ const lines = stdoutBuffer.split(/\r?\n/);
555
+ stdoutBuffer = lines.pop();
556
+ lines.forEach(parseEntryLine);
557
+ });
558
+ proc.stderr.on('data', (chunk) => {
559
+ stderrBuffer += stderrDecoder.write(chunk);
560
+ const lines = stderrBuffer.split(/\r?\n/);
561
+ stderrBuffer = lines.pop();
562
+ for (const line of lines) {
563
+ const cleaned = sanitizeLogLine(line);
564
+ if (cleaned) {
565
+ placeholder.logLines.push(cleaned);
566
+ if (placeholder.logLines.length > MAX_LOG_LINES) placeholder.logLines.shift();
567
+ }
568
+ }
569
+ this.onUpdate();
570
+ });
571
+ proc.on('error', (error) => {
572
+ processError = `yt-dlp playlist scan error: ${error.message}`;
573
+ });
574
+ proc.on('close', (code) => {
575
+ stdoutBuffer += stdoutDecoder.end();
576
+ parseEntryLine(stdoutBuffer);
577
+ stderrBuffer += stderrDecoder.end();
578
+ const lastErrorLine = sanitizeLogLine(stderrBuffer);
579
+ if (lastErrorLine) placeholder.logLines.push(lastErrorLine);
580
+ placeholder.proc = null;
581
+
582
+ if (!this.items.includes(placeholder) || placeholder.status === 'Cancelled') return;
583
+ if (code !== 0 || processError || entries.length === 0) {
584
+ placeholder.status = 'Error';
585
+ placeholder.errorMsg =
586
+ processError || lastErrorLine || (entries.length === 0 ? 'Плейлист пуст или не удалось получить список.' : `Exit code ${code}`);
587
+ placeholder.logLines.push(sanitizeLogLine(placeholder.errorMsg));
588
+ this._startNextQueued();
589
+ this.onUpdate();
590
+ return;
591
+ }
592
+
593
+ const expanded = entries
594
+ .map((entry, index) => {
595
+ const entryUrl = playlistEntryUrl(entry, placeholder.url);
596
+ return entryUrl ? this._createQueueItem(opts, entryUrl, null, entry, index) : null;
597
+ })
598
+ .filter(Boolean);
599
+ if (expanded.length === 0) {
600
+ placeholder.status = 'Error';
601
+ placeholder.errorMsg = 'yt-dlp не вернул ссылок на треки плейлиста.';
602
+ placeholder.logLines.push(placeholder.errorMsg);
603
+ this._startNextQueued();
604
+ this.onUpdate();
605
+ return;
606
+ }
607
+
608
+ const index = this.items.indexOf(placeholder);
609
+ if (index === -1) return;
610
+ for (const item of expanded) item.startImmediately = placeholder.startImmediately;
611
+ this.items.splice(index, 1, ...expanded);
612
+ this._startNextQueued();
613
+ this.onUpdate();
614
+ });
615
+ }
616
+
617
+ _startNextQueued() {
618
+ if (this.items.some((item) => item.status === 'Downloading' || item.status === 'Paused')) return;
619
+ const next = this.items.find((item) => item.status === 'Queued' || item.status === 'Preparing');
620
+ if (!next || next.status === 'Preparing' || !next.startImmediately) return;
621
+ this.startItem(next);
622
+ }
623
+
624
+ _retryOrFinish(item, errorMsg) {
625
+ item.errorMsg = errorMsg;
626
+ if (item.retryCount < MAX_DOWNLOAD_RETRIES) {
627
+ item.retryCount += 1;
628
+ item.status = 'Queued';
629
+ item.progress = 0;
630
+ item.speedStr = '';
631
+ item.etaStr = '';
632
+ item.sizeStr = '—';
633
+ item.logLines.push(`Ошибка загрузки. Повтор ${item.retryCount}/${MAX_DOWNLOAD_RETRIES}: ${errorMsg}`);
634
+ const currentIndex = this.items.indexOf(item);
635
+ if (currentIndex !== -1) {
636
+ this.items.splice(currentIndex, 1);
637
+ this.items.push(item);
638
+ }
639
+ } else {
640
+ item.status = 'Error';
641
+ item.logLines.push(`Загрузка не удалась после ${MAX_DOWNLOAD_RETRIES} повторов: ${errorMsg}`);
376
642
  }
377
- this.onUpdate();
378
- return item;
379
643
  }
380
644
 
381
645
  _updateFinalFileSize(item) {
@@ -432,7 +696,7 @@ class DownloadManager {
432
696
  }
433
697
 
434
698
  startItem(item) {
435
- if (item.status === 'Downloading') return;
699
+ if (item.status === 'Downloading' || item.proc) return;
436
700
  item.status = 'Downloading';
437
701
  item.errorMsg = '';
438
702
 
@@ -454,6 +718,7 @@ class DownloadManager {
454
718
  item.logLines.push('Error: yt-dlp binary not found. Install via brew/apt/winget or download standalone binary.');
455
719
  }
456
720
  this.onUpdate();
721
+ this._startNextQueued();
457
722
  return;
458
723
  }
459
724
 
@@ -467,14 +732,31 @@ class DownloadManager {
467
732
  const proc = spawn(this.ytdlpPath, item.cmdArgs, {
468
733
  stdio: ['ignore', 'pipe', 'pipe'],
469
734
  env: childEnv,
735
+ detached: process.platform !== 'win32',
470
736
  });
471
737
  item.proc = proc;
472
738
 
739
+ let updateTimer = null;
740
+ const scheduleUpdate = () => {
741
+ if (updateTimer) return;
742
+ updateTimer = setTimeout(() => {
743
+ updateTimer = null;
744
+ this.onUpdate();
745
+ }, 100);
746
+ };
747
+ const flushUpdate = () => {
748
+ if (updateTimer) {
749
+ clearTimeout(updateTimer);
750
+ updateTimer = null;
751
+ }
752
+ this.onUpdate();
753
+ };
754
+
473
755
  const handleLine = (line) => {
474
- const trimmed = line.trim();
756
+ const trimmed = sanitizeLogLine(line);
475
757
  if (!trimmed) return;
476
758
  item.logLines.push(trimmed);
477
- if (item.logLines.length > 200) item.logLines.shift();
759
+ if (item.logLines.length > MAX_LOG_LINES) item.logLines.shift();
478
760
 
479
761
  const mDest = this.destRe.exec(trimmed) || this.mergerRe.exec(trimmed) || this.alreadyRe.exec(trimmed);
480
762
  if (mDest) {
@@ -503,7 +785,7 @@ class DownloadManager {
503
785
  item.speedStr = mProg[3] || '';
504
786
  item.etaStr = mProg[4] || '';
505
787
  }
506
- this.onUpdate();
788
+ scheduleUpdate();
507
789
  };
508
790
 
509
791
  const stdoutDecoder = new StringDecoder('utf8');
@@ -518,6 +800,14 @@ class DownloadManager {
518
800
  for (const p of parts) handleLine(p);
519
801
  });
520
802
 
803
+ proc.on('error', (error) => {
804
+ item.proc = null;
805
+ item.status = 'Error';
806
+ item.errorMsg = `yt-dlp process error: ${error.message}`;
807
+ item.logLines.push(sanitizeLogLine(item.errorMsg));
808
+ flushUpdate();
809
+ });
810
+
521
811
  proc.stderr.on('data', (chunk) => {
522
812
  stderrBuf += stderrDecoder.write(chunk);
523
813
  const parts = stderrBuf.split(/[\r\n]+/);
@@ -526,6 +816,10 @@ class DownloadManager {
526
816
  });
527
817
 
528
818
  proc.on('close', (code) => {
819
+ if (item.killTimer) {
820
+ clearTimeout(item.killTimer);
821
+ item.killTimer = null;
822
+ }
529
823
  const remOut = stdoutBuf + stdoutDecoder.end();
530
824
  const remErr = stderrBuf + stderrDecoder.end();
531
825
  if (remOut.trim()) handleLine(remOut);
@@ -533,18 +827,20 @@ class DownloadManager {
533
827
 
534
828
  item.proc = null;
535
829
  if (item.status === 'Cancelled' || item.status === 'Paused') {
536
- this.onUpdate();
830
+ flushUpdate();
831
+ this._startNextQueued();
537
832
  return;
538
833
  }
539
834
  if (code === 0) {
540
835
  item.progress = 100;
541
836
  item.status = 'Done';
837
+ item.errorMsg = '';
542
838
  this._updateFinalFileSize(item);
543
839
  } else {
544
- item.status = 'Error';
545
- item.errorMsg = item.logLines[item.logLines.length - 1] || `Exit code ${code}`;
840
+ this._retryOrFinish(item, item.logLines[item.logLines.length - 1] || `Exit code ${code}`);
546
841
  }
547
- this.onUpdate();
842
+ flushUpdate();
843
+ this._startNextQueued();
548
844
  });
549
845
  }
550
846
 
@@ -552,11 +848,14 @@ class DownloadManager {
552
848
  if (item.status !== 'Downloading') return;
553
849
  if (item.proc) {
554
850
  try {
555
- item.proc.kill('SIGSTOP');
556
- item.status = 'Paused';
557
- } catch (_) {
558
- item.proc.kill('SIGTERM');
851
+ if (process.platform === 'win32') {
852
+ setWindowsProcessTreeState(item.proc.pid, 'pause');
853
+ } else {
854
+ process.kill(-item.proc.pid, 'SIGSTOP');
855
+ }
559
856
  item.status = 'Paused';
857
+ } catch (error) {
858
+ item.logLines.push(sanitizeLogLine(`Pause failed: ${error.message}`));
560
859
  }
561
860
  } else if (item.simTimer) {
562
861
  item.status = 'Paused';
@@ -567,35 +866,81 @@ class DownloadManager {
567
866
  resumeItem(item) {
568
867
  if (item.status === 'Paused' && item.proc) {
569
868
  try {
570
- item.proc.kill('SIGCONT');
869
+ if (process.platform === 'win32') {
870
+ setWindowsProcessTreeState(item.proc.pid, 'resume');
871
+ } else {
872
+ process.kill(-item.proc.pid, 'SIGCONT');
873
+ }
571
874
  item.status = 'Downloading';
572
875
  this.onUpdate();
573
876
  return;
574
- } catch (_) {}
877
+ } catch (error) {
878
+ item.logLines.push(sanitizeLogLine(`Resume failed: ${error.message}`));
879
+ this.onUpdate();
880
+ return;
881
+ }
575
882
  }
576
883
  if (item.status === 'Paused' && item.simTimer) {
577
884
  item.status = 'Downloading';
578
885
  this.onUpdate();
579
886
  return;
580
887
  }
581
- if (['Paused', 'Queued', 'Error', 'Cancelled'].includes(item.status)) {
582
- this.startItem(item);
888
+ if (['Queued', 'Error', 'Cancelled'].includes(item.status)) {
889
+ if (item.status === 'Error' || item.status === 'Cancelled') item.retryCount = 0;
890
+ item.status = 'Queued';
891
+ this._startNextQueued();
583
892
  }
584
893
  }
585
894
 
586
895
  cancelItem(item) {
896
+ const waitingForProcessExit = Boolean(item.proc);
587
897
  if (item.simTimer) {
588
898
  clearInterval(item.simTimer);
589
899
  item.simTimer = null;
590
900
  }
591
901
  if (item.proc) {
592
- try {
593
- item.proc.kill('SIGTERM');
594
- } catch (_) {}
595
- item.proc = null;
902
+ const proc = item.proc;
903
+ if (process.platform === 'win32') {
904
+ const killer = spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F'], {
905
+ stdio: 'ignore',
906
+ windowsHide: true,
907
+ });
908
+ killer.on('error', (error) => {
909
+ if (item.proc !== proc) return;
910
+ item.status = 'Error';
911
+ item.errorMsg = `Unable to stop yt-dlp process tree: ${error.message}`;
912
+ item.logLines.push(sanitizeLogLine(item.errorMsg));
913
+ this.onUpdate();
914
+ });
915
+ killer.on('close', (code) => {
916
+ if (item.proc !== proc || proc.exitCode !== null) return;
917
+ if (code !== 0) {
918
+ item.status = 'Error';
919
+ item.errorMsg = `taskkill exited with code ${code}`;
920
+ item.logLines.push(sanitizeLogLine(item.errorMsg));
921
+ this.onUpdate();
922
+ }
923
+ });
924
+ } else {
925
+ try {
926
+ process.kill(-proc.pid, 'SIGTERM');
927
+ item.killTimer = setTimeout(() => {
928
+ if (item.proc !== proc || proc.exitCode !== null) return;
929
+ try {
930
+ process.kill(-proc.pid, 'SIGKILL');
931
+ } catch (error) {
932
+ item.logLines.push(sanitizeLogLine(`Unable to force-stop yt-dlp: ${error.message}`));
933
+ this.onUpdate();
934
+ }
935
+ }, 1500);
936
+ } catch (error) {
937
+ item.logLines.push(sanitizeLogLine(`Unable to stop yt-dlp process tree: ${error.message}`));
938
+ }
939
+ }
596
940
  }
597
941
  item.status = 'Cancelled';
598
942
  this.onUpdate();
943
+ if (!waitingForProcessExit) this._startNextQueued();
599
944
  }
600
945
 
601
946
  removeItem(idx) {
@@ -627,6 +972,7 @@ class DownloadManager {
627
972
  clearInterval(item.simTimer);
628
973
  item.simTimer = null;
629
974
  item.status = 'Done';
975
+ this._startNextQueued();
630
976
  }
631
977
  this.onUpdate();
632
978
  }, 120);
@@ -657,6 +1003,11 @@ class ScreenBuffer {
657
1003
  }
658
1004
  }
659
1005
 
1006
+ putStrWithin(y, x, text, width, style = ANSI.fgWhite) {
1007
+ if (width <= 0) return;
1008
+ this.putStr(y, x, String(text).slice(0, width), style);
1009
+ }
1010
+
660
1011
  drawBox(y, x, h, w, title = '', style = ANSI.fgWhite, titleStyle = ANSI.bold + ANSI.fgCyan) {
661
1012
  if (h < 2 || w < 4) return;
662
1013
  this.putStr(y, x, '┌' + '─'.repeat(w - 2) + '┐', style);
@@ -671,7 +1022,7 @@ class ScreenBuffer {
671
1022
  }
672
1023
 
673
1024
  renderToAnsi() {
674
- let out = `${CSI}H`;
1025
+ let out = '';
675
1026
  for (let r = 0; r < this.rows; r++) {
676
1027
  let curStyle = '';
677
1028
  let line = '';
@@ -684,8 +1035,7 @@ class ScreenBuffer {
684
1035
  line += this.chars[r][c];
685
1036
  }
686
1037
  line += ANSI.reset;
687
- if (r < this.rows - 1) line += '\r\n';
688
- out += line;
1038
+ out += `${CSI}${r + 1};1H${CSI}2K${line}`;
689
1039
  }
690
1040
  return out;
691
1041
  }
@@ -702,6 +1052,7 @@ class EvaNodeTUI {
702
1052
  this.focusOrder = [];
703
1053
  this.hitboxes = [];
704
1054
  this.selectedQueueIdx = 0;
1055
+ this.selectedQueueItem = null;
705
1056
  this.statusBanner = '';
706
1057
 
707
1058
  // Inline modal prompt state
@@ -843,6 +1194,12 @@ class EvaNodeTUI {
843
1194
  // -------------------------------------------------------------------------
844
1195
 
845
1196
  render() {
1197
+ if (this.selectedQueueItem) {
1198
+ const selectedIndex = this.manager.items.indexOf(this.selectedQueueItem);
1199
+ if (selectedIndex >= 0) this.selectedQueueIdx = selectedIndex;
1200
+ else this.selectedQueueItem = null;
1201
+ }
1202
+
846
1203
  const cols = process.stdout.columns || 100;
847
1204
  const rows = process.stdout.rows || 28;
848
1205
  const scr = new ScreenBuffer(cols, rows);
@@ -860,7 +1217,7 @@ class EvaNodeTUI {
860
1217
  ASCII_LOGO.forEach((line, i) => {
861
1218
  scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
862
1219
  });
863
- scr.putStr(7, 2, 'EVA MEDIA DOWNLOADER — by .maxon', ANSI.fgCyan);
1220
+ scr.putStr(7, 2, 'EVA MEDIA DOWNLOADER', ANSI.fgCyan);
864
1221
  scr.putStr(2, cols - APP_VERSION.length - 3, APP_VERSION, ANSI.fgWhite);
865
1222
  if (cols >= 90) {
866
1223
  const tagline = 'Music. Videos. Your collection.';
@@ -887,6 +1244,7 @@ class EvaNodeTUI {
887
1244
  this.hitboxes.push({
888
1245
  id: `tab_${nav.tab}`,
889
1246
  y: r,
1247
+ nav: true,
890
1248
  x1: 2,
891
1249
  x2: 2 + padded.length - 1,
892
1250
  action: () => {
@@ -921,7 +1279,7 @@ class EvaNodeTUI {
921
1279
  scr.putStr(rows - 2, prefix.length, this.modal.value.padEnd(Math.max(1, cols - prefix.length - 1), ' '), ANSI.fgYellow + ANSI.bold);
922
1280
  scr.putStr(rows - 1, 0, ' [Enter] Применить [Esc] Отмена [Ctrl+U] Очистить '.padEnd(cols - 1, ' '), ANSI.bgWhite + ANSI.fgBlack);
923
1281
  } else {
924
- const footerLeft = 'EVA.md • yt-dlp';
1282
+ const footerLeft = 'EVA.md x yt-dlp';
925
1283
  scr.putStr(rows - 1, 2, footerLeft, ANSI.fgCyan + ANSI.bold);
926
1284
 
927
1285
  const items = this.manager.items;
@@ -1217,17 +1575,26 @@ class EvaNodeTUI {
1217
1575
 
1218
1576
  _renderQueueBox(scr, y, x, h, w) {
1219
1577
  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);
1578
+ const contentWidth = w - 4;
1579
+ const titleX = 4;
1580
+ const statusColW = Math.max(1, Math.min(29, Math.floor(contentWidth * 0.28), contentWidth - 26));
1581
+ const statusX = contentWidth - statusColW - 1;
1582
+ const sizeX = statusX - 1 - 10;
1583
+ const formatX = sizeX - 1 - 7;
1584
+ const titleColW = Math.max(0, formatX - titleX - 1);
1585
+ scr.putStr(y + 1, x + 2 + titleX, 'Title'.slice(0, titleColW), ANSI.fgCyan + ANSI.bold);
1586
+ scr.putStr(y + 1, x + 2 + formatX, 'Format', ANSI.fgCyan + ANSI.bold);
1587
+ scr.putStr(y + 1, x + 2 + sizeX, 'Size', ANSI.fgCyan + ANSI.bold);
1588
+ scr.putStr(y + 1, x + 2 + statusX, 'Status', ANSI.fgCyan + ANSI.bold);
1589
+ scr.putStr(y + 2, x, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1224
1590
 
1225
1591
  const items = this.manager.items;
1226
1592
  if (this.selectedQueueIdx >= items.length) {
1227
1593
  this.selectedQueueIdx = Math.max(0, items.length - 1);
1228
1594
  }
1595
+ this.selectedQueueItem = items[this.selectedQueueIdx] || null;
1229
1596
 
1230
- const listRows = Math.max(1, h - 5);
1597
+ const listRows = Math.max(1, h - 6);
1231
1598
  if (items.length === 0) {
1232
1599
  scr.putStr(y + 3, x + 4, 'Очередь пуста. Добавьте трек во вкладке [1] Download.', ANSI.fgWhite);
1233
1600
  } else {
@@ -1242,15 +1609,18 @@ class EvaNodeTUI {
1242
1609
  const filled = Math.round((it.progress / 100) * barW);
1243
1610
  const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
1244
1611
  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)}`;
1612
+ let statusText;
1613
+ if (it.status === 'Done') {
1614
+ statusText = '✓ Done';
1615
+ } else if (it.status === 'Error') {
1616
+ statusText = '✗ Error';
1617
+ } else {
1618
+ statusText = `[${bar}] ${pct} ${it.status}`;
1619
+ if (it.retryCount > 0) statusText += ` (${it.retryCount}/${MAX_DOWNLOAD_RETRIES})`;
1248
1620
  }
1621
+ statusText = statusText.slice(0, statusColW).padEnd(statusColW);
1249
1622
 
1250
1623
  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
1624
  const st = isSel
1255
1625
  ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold
1256
1626
  : it.status === 'Done'
@@ -1259,7 +1629,12 @@ class EvaNodeTUI {
1259
1629
  ? ANSI.fgRed
1260
1630
  : ANSI.fgWhite;
1261
1631
 
1262
- scr.putStr(ry, x + 2, line.slice(0, w - 4), st);
1632
+ scr.putStr(ry, x + 2, `${pointer}${String(seqNum).padEnd(2)} `, st);
1633
+ const title = fitTextToCellWidth(it.title, titleColW);
1634
+ scr.putStrWithin(ry, x + 2 + titleX, title, titleColW, st);
1635
+ scr.putStr(ry, x + 2 + formatX, it.fmt.slice(0, 7).padEnd(7), st);
1636
+ scr.putStr(ry, x + 2 + sizeX, it.sizeStr.slice(0, 10).padEnd(10), st);
1637
+ scr.putStr(ry, x + 2 + statusX, statusText, st);
1263
1638
  this.hitboxes.push({
1264
1639
  id: `qitem_${idx}`,
1265
1640
  y: ry,
@@ -1267,6 +1642,7 @@ class EvaNodeTUI {
1267
1642
  x2: x + w - 3,
1268
1643
  action: () => {
1269
1644
  this.selectedQueueIdx = idx;
1645
+ this.selectedQueueItem = it;
1270
1646
  this.render();
1271
1647
  },
1272
1648
  });
@@ -1274,14 +1650,16 @@ class EvaNodeTUI {
1274
1650
  }
1275
1651
 
1276
1652
  const ctrlY = y + h - 2;
1277
- scr.putStr(ctrlY - 1, x + 1, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1653
+ scr.putStr(ctrlY - 1, x, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1278
1654
  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;
1655
+ cx = this.registerWidget(scr, 'q_resume', ctrlY, cx, '[r]Resume', () => this._qAction('resume')) + 1;
1656
+ cx = this.registerWidget(scr, 'q_pause', ctrlY, cx, '[p]Pause', () => this._qAction('pause')) + 1;
1657
+ cx = this.registerWidget(scr, 'q_cancel', ctrlY, cx, '[c]Cancel', () => this._qAction('cancel')) + 1;
1658
+ cx = this.registerWidget(scr, 'q_remove', ctrlY, cx, '[d]Remove', () => this._qAction('remove')) + 1;
1283
1659
  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);
1660
+ const activeLabel = `│ Active: ${activeCnt}/${items.length}`;
1661
+ const activeX = x + w - activeLabel.length - 2;
1662
+ if (activeX > cx) scr.putStr(ctrlY, activeX, activeLabel, ANSI.fgCyan);
1285
1663
  }
1286
1664
 
1287
1665
  _renderQueueTab(scr, x, w, rows) {
@@ -1326,22 +1704,31 @@ class EvaNodeTUI {
1326
1704
  }
1327
1705
  } catch (_) {}
1328
1706
 
1329
- const nameColW = Math.max(20, w - 28);
1707
+ const contentWidth = w - 6;
1708
+ const numberColW = 4;
1709
+ const formatColW = 7;
1710
+ const sizeColW = 10;
1711
+ const nameColW = Math.min(60, Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5));
1712
+ const formatX = numberColW + 1 + nameColW + 1;
1713
+ const sizeX = formatX + formatColW + 1;
1330
1714
  if (files.length === 0) {
1331
1715
  scr.putStr(10, x + 3, `В папке ${outDir} пока нет скачанных медиафайлов.`.slice(0, w - 6), ANSI.fgWhite);
1332
1716
  } 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) => {
1717
+ scr.putStr(10, x + 3, '#', ANSI.fgCyan + ANSI.bold);
1718
+ scr.putStr(10, x + 3 + numberColW + 1, 'Имя файла', ANSI.fgCyan + ANSI.bold);
1719
+ scr.putStr(10, x + 3 + formatX, 'Формат', ANSI.fgCyan + ANSI.bold);
1720
+ scr.putStr(10, x + 3 + sizeX, 'Размер', ANSI.fgCyan + ANSI.bold);
1721
+ const visibleFileCount = Math.max(0, Math.min(files.length, rows - 15));
1722
+ files.slice(0, visibleFileCount).forEach((f, i) => {
1340
1723
  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);
1724
+ const row = 12 + i;
1725
+ const filename = f.name.length > nameColW
1726
+ ? `${f.name.slice(0, Math.max(0, nameColW - 1))}…`
1727
+ : f.name;
1728
+ scr.putStr(row, x + 3, String(i + 1).padEnd(numberColW), ANSI.fgWhite);
1729
+ scr.putStr(row, x + 3 + numberColW + 1, filename.slice(0, nameColW).padEnd(nameColW), ANSI.fgWhite);
1730
+ scr.putStr(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), ANSI.fgWhite);
1731
+ scr.putStr(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), ANSI.fgWhite);
1345
1732
  });
1346
1733
  }
1347
1734
  }
@@ -1482,6 +1869,9 @@ class EvaNodeTUI {
1482
1869
  ' • Файлы: -o "%(playlist_index)s. %(title)s.%(ext)s", --download-archive archive.txt',
1483
1870
  ' • Плейлисты: --playlist-items 1-5, --playlist-reverse',
1484
1871
  ' • Сеть (вкладка Settings): -4, --proxy http://127.0.0.1:8080, --cookies-from-browser chrome',
1872
+ ` `,
1873
+ ` `,
1874
+ `by .maxon `,
1485
1875
  ];
1486
1876
  lines.forEach((line, i) => {
1487
1877
  if (10 + i < rows - 2) scr.putStr(10 + i, x + 3, line.slice(0, w - 6), ANSI.fgWhite);
@@ -1531,6 +1921,7 @@ class EvaNodeTUI {
1531
1921
  if (this.opts.url.trim()) {
1532
1922
  const it = this.manager.addTask(this.opts, true);
1533
1923
  this.selectedQueueIdx = this.manager.items.length - 1;
1924
+ this.selectedQueueItem = it;
1534
1925
  this.statusBanner = `+ #${this.manager.items.length} (${it.sourceName})`;
1535
1926
  this.render();
1536
1927
  }
@@ -1539,6 +1930,7 @@ class EvaNodeTUI {
1539
1930
  }
1540
1931
  const item = this.manager.addTask(this.opts, true);
1541
1932
  this.selectedQueueIdx = this.manager.items.length - 1;
1933
+ this.selectedQueueItem = item;
1542
1934
  this.statusBanner = `+ #${this.manager.items.length} (${item.sourceName})`;
1543
1935
  this.render();
1544
1936
  }
@@ -1549,7 +1941,10 @@ class EvaNodeTUI {
1549
1941
  if (type === 'resume') this.manager.resumeItem(it);
1550
1942
  else if (type === 'pause') this.manager.pauseItem(it);
1551
1943
  else if (type === 'cancel') this.manager.cancelItem(it);
1552
- else if (type === 'remove') this.manager.removeItem(this.selectedQueueIdx);
1944
+ else if (type === 'remove') {
1945
+ this.manager.removeItem(this.selectedQueueIdx);
1946
+ this.selectedQueueItem = null;
1947
+ }
1553
1948
  this.render();
1554
1949
  }
1555
1950
 
@@ -1596,7 +1991,9 @@ class EvaNodeTUI {
1596
1991
  const my = parseInt(mouseMatch[3], 10) - 1;
1597
1992
  const isPress = mouseMatch[4] === 'M';
1598
1993
  if (isPress && btn === 0) {
1599
- const hit = this.hitboxes.find((h) => h.y === my && mx >= h.x1 && mx <= h.x2);
1994
+ const hit = this.hitboxes.find((h) =>
1995
+ mx >= h.x1 && mx <= h.x2 && (h.y === my || (h.nav && Math.abs(h.y - my) === 1))
1996
+ );
1600
1997
  if (hit) {
1601
1998
  this.focusId = hit.id;
1602
1999
  hit.action();
@@ -1740,9 +2137,13 @@ if (require.main === module) {
1740
2137
  module.exports = {
1741
2138
  createDefaultOptions,
1742
2139
  buildYtdlpArgs,
2140
+ buildPlaylistDiscoveryArgs,
1743
2141
  buildCommandPreview,
1744
2142
  detectSource,
2143
+ isYouTubePlaylistUrl,
2144
+ playlistEntryUrl,
1745
2145
  DownloadManager,
1746
2146
  formatBytesToMB,
2147
+ fitTextToCellWidth,
1747
2148
  wrapTextByWords,
1748
2149
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eva-md",
3
- "version": "0.3.3",
3
+ "version": "0.4.3",
4
4
  "description": "EVA Media Downloader CLI",
5
5
  "main": "eva.js",
6
6
  "bin": {