eva-md 0.3.3 → 0.5.1

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 +684 -230
  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.5.1 — 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.5.1 (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 = [
@@ -100,7 +104,6 @@ const ANSI = {
100
104
  fgBlack: `${CSI}30m`,
101
105
  fgGray: `${CSI}38;2;105;105;105m`,
102
106
  fgOrange: `${CSI}38;2;222;91;9m`, // Текст цвета RGB(222, 91, 9)
103
- bgOrange: `${CSI}48;2;222;91;9m`,
104
107
  bgGray: `${CSI}48;2;105;105;105m`,
105
108
  bgWhite: `${CSI}47m`,
106
109
  bgCyan: `${CSI}46m`,
@@ -139,11 +142,88 @@ function shellQuote(arg) {
139
142
  return arg;
140
143
  }
141
144
 
145
+ function resolveOutputDir(outputDir) {
146
+ return outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
147
+ }
148
+
142
149
  function formatBytesToMB(bytes) {
143
150
  if (!bytes || bytes <= 0) return '—';
144
151
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
145
152
  }
146
153
 
154
+ function sanitizeLogLine(line) {
155
+ return String(line)
156
+ .replace(ANSI_ESCAPE_RE, '')
157
+ .replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
158
+ .trim()
159
+ .slice(0, MAX_LOG_LINE_LENGTH);
160
+ }
161
+
162
+ function fitTextToCellWidth(text, width) {
163
+ const chars = [];
164
+ let totalCells = 0;
165
+ for (const char of String(text)) {
166
+ const code = char.codePointAt(0);
167
+ const zeroWidth =
168
+ /\p{Mark}/u.test(char) ||
169
+ code === 0x200d ||
170
+ (code >= 0xfe00 && code <= 0xfe0f) ||
171
+ (code >= 0xe0100 && code <= 0xe01ef);
172
+ if (zeroWidth) continue;
173
+ const isWide =
174
+ (code >= 0x1100 && code <= 0x11ff) ||
175
+ (code >= 0x2e80 && code <= 0xa4cf) ||
176
+ (code >= 0x20000 && code <= 0x3fffd) ||
177
+ (code >= 0xac00 && code <= 0xd7af) ||
178
+ (code >= 0xf900 && code <= 0xfaff) ||
179
+ (code >= 0xfe10 && code <= 0xfe6f) ||
180
+ (code >= 0xff01 && code <= 0xff60) ||
181
+ (code >= 0xffe0 && code <= 0xffe6) ||
182
+ (code >= 0x1f000 && code <= 0x1faff);
183
+ chars.push({ value: isWide || code > 0xffff ? '?' : char, width: 1 });
184
+ totalCells += 1;
185
+ }
186
+
187
+ const clipped = totalCells > width;
188
+ const contentWidth = clipped ? Math.max(0, width - 1) : width;
189
+ let result = '';
190
+ let cells = 0;
191
+ for (const char of chars) {
192
+ if (cells + char.width > contentWidth) break;
193
+ result += char.value;
194
+ cells += char.width;
195
+ }
196
+ if (clipped && width > 0) {
197
+ result += '…';
198
+ cells += 1;
199
+ }
200
+ return result + ' '.repeat(Math.max(0, width - cells));
201
+ }
202
+
203
+ function setWindowsProcessTreeState(rootPid, action) {
204
+ const script = [
205
+ `$rootId = ${rootPid}`,
206
+ "$operation = '" + action + "'",
207
+ "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); }'",
208
+ '$processes = Get-CimInstance Win32_Process',
209
+ '$targets = New-Object System.Collections.Generic.List[int]',
210
+ '$targets.Add([int]$rootId)',
211
+ '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) } } }',
212
+ '$ordered = @($targets.ToArray())',
213
+ 'if ($operation -eq "pause") { [array]::Reverse($ordered) }',
214
+ '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) } }',
215
+ ].join('; ');
216
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
217
+ encoding: 'utf8',
218
+ windowsHide: true,
219
+ timeout: 10000,
220
+ });
221
+ if (result.error) throw result.error;
222
+ if (result.status !== 0) {
223
+ throw new Error((result.stderr || result.stdout || `PowerShell exited with code ${result.status}`).trim());
224
+ }
225
+ }
226
+
147
227
  function wrapTextByWords(text, maxWidth) {
148
228
  if (maxWidth <= 10) return [text.slice(0, Math.max(1, maxWidth))];
149
229
  const words = String(text).split(' ');
@@ -264,7 +344,7 @@ function buildYtdlpArgs(opts, targetUrl = null) {
264
344
  if (opts.writeThumbnail) args.push('--write-thumbnail');
265
345
 
266
346
  // File Naming & Archive
267
- const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
347
+ const outDir = resolveOutputDir(opts.outputDir);
268
348
  const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
269
349
  args.push('-o', path.join(outDir, tpl));
270
350
 
@@ -324,6 +404,40 @@ function detectSource(url, defaultId = 1) {
324
404
  return { id: found.id, name: found.name };
325
405
  }
326
406
 
407
+ function isYouTubePlaylistUrl(url) {
408
+ try {
409
+ const parsed = new URL(url);
410
+ return /(^|\.)youtube\.com$/i.test(parsed.hostname) && Boolean(parsed.searchParams.get('list'));
411
+ } catch (_) {
412
+ return false;
413
+ }
414
+ }
415
+
416
+ function buildPlaylistDiscoveryArgs(opts, url) {
417
+ const args = ['--flat-playlist', '--dump-json', '--skip-download', '--no-warnings', '--encoding', 'utf-8'];
418
+ if (opts.usePlaylistItems && opts.playlistItems.trim()) {
419
+ args.push('--playlist-items', opts.playlistItems.trim());
420
+ }
421
+ if (opts.playlistReverse) args.push('--playlist-reverse');
422
+ if (opts.forceIpv4) args.push('-4');
423
+ if (opts.useProxy && opts.proxyUrl.trim()) args.push('--proxy', opts.proxyUrl.trim());
424
+ if (opts.useCookiesBrowser && opts.cookiesBrowser && opts.cookiesBrowser !== 'none') {
425
+ args.push('--cookies-from-browser', opts.cookiesBrowser);
426
+ }
427
+ args.push(url);
428
+ return args;
429
+ }
430
+
431
+ function playlistEntryUrl(entry, sourceUrl) {
432
+ for (const candidate of [entry.webpage_url, entry.original_url, entry.url]) {
433
+ if (typeof candidate === 'string' && /^https?:\/\//i.test(candidate)) return candidate;
434
+ }
435
+ if (entry.id && isYouTubePlaylistUrl(sourceUrl)) {
436
+ return `https://www.youtube.com/watch?v=${encodeURIComponent(entry.id)}`;
437
+ }
438
+ return '';
439
+ }
440
+
327
441
  // ---------------------------------------------------------------------------
328
442
  // Download Manager (Subprocess Spawner & Real-Time Progress Parser)
329
443
  // ---------------------------------------------------------------------------
@@ -345,16 +459,43 @@ class DownloadManager {
345
459
 
346
460
  addTask(opts, startImmediately = true) {
347
461
  const url = opts.url.trim();
462
+ const taskOpts = { ...opts };
463
+ if (isYouTubePlaylistUrl(url)) {
464
+ const item = this._createQueueItem(taskOpts, url, 'Получение списка плейлиста…');
465
+ item.status = 'Preparing';
466
+ item.isPlaylistExpansion = true;
467
+ item.startImmediately = startImmediately;
468
+ item.cmdArgs = buildPlaylistDiscoveryArgs(taskOpts, url);
469
+ this.items.push(item);
470
+ this._expandPlaylist(item, taskOpts);
471
+ this.onUpdate();
472
+ return item;
473
+ }
474
+
475
+ const item = this._createQueueItem(taskOpts, url);
476
+ item.startImmediately = startImmediately;
477
+ this.items.push(item);
478
+ if (startImmediately) this._startNextQueued();
479
+ this.onUpdate();
480
+ return item;
481
+ }
482
+
483
+ _createQueueItem(opts, url, title = null, entry = null, entryIndex = 0) {
348
484
  const src = detectSource(url, opts.sourceId);
349
485
  const fmt = opts.bestVideoMerge && !opts.extractAudio ? 'mp4/mkv' : opts.audioFormat;
350
- const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
351
- const cmdArgs = buildYtdlpArgs(opts, url);
486
+ const outDir = resolveOutputDir(opts.outputDir);
487
+ const itemOpts = { ...opts, usePlaylistItems: false, playlistReverse: false };
488
+ if (entry && itemOpts.usePlaylistTemplate) {
489
+ const playlistIndex = String(entry.playlist_index || entryIndex + 1).padStart(2, '0');
490
+ itemOpts.outputTemplate = itemOpts.outputTemplate.replace(/%\(\s*playlist_index\s*\)s/g, playlistIndex);
491
+ }
492
+ const cmdArgs = entry ? buildYtdlpArgs(itemOpts, url) : buildYtdlpArgs(opts, url);
352
493
 
353
494
  let shortTitle = url.replace(/^https?:\/\//, '');
354
- const item = {
495
+ return {
355
496
  id: this.nextId++,
356
497
  url,
357
- title: `[${src.name}] ${shortTitle.slice(0, 44)}`,
498
+ title: entry && entry.title ? String(entry.title) : title || `[${src.name}] ${shortTitle.slice(0, 44)}`,
358
499
  sourceName: src.name,
359
500
  fmt,
360
501
  outDir,
@@ -364,18 +505,144 @@ class DownloadManager {
364
505
  speedStr: '',
365
506
  etaStr: '',
366
507
  status: 'Queued',
508
+ retryCount: 0,
367
509
  cmdArgs,
368
510
  errorMsg: '',
369
511
  proc: null,
370
512
  simTimer: null,
513
+ killTimer: null,
371
514
  logLines: [],
372
515
  };
373
- this.items.push(item);
374
- if (startImmediately) {
375
- this.startItem(item);
516
+ }
517
+
518
+ _expandPlaylist(placeholder, opts) {
519
+ if (!this.ytdlpPath) {
520
+ placeholder.status = 'Error';
521
+ placeholder.errorMsg = 'yt-dlp not found in PATH';
522
+ placeholder.logLines.push(placeholder.errorMsg);
523
+ this.onUpdate();
524
+ return;
525
+ }
526
+
527
+ const proc = spawn(this.ytdlpPath, placeholder.cmdArgs, {
528
+ stdio: ['ignore', 'pipe', 'pipe'],
529
+ env: Object.assign({}, process.env, {
530
+ PYTHONIOENCODING: 'utf-8',
531
+ PYTHONUTF8: '1',
532
+ LANG: 'en_US.UTF-8',
533
+ LC_ALL: 'en_US.UTF-8',
534
+ }),
535
+ detached: process.platform !== 'win32',
536
+ });
537
+ placeholder.proc = proc;
538
+
539
+ const stdoutDecoder = new StringDecoder('utf8');
540
+ const stderrDecoder = new StringDecoder('utf8');
541
+ let stdoutBuffer = '';
542
+ let stderrBuffer = '';
543
+ const entries = [];
544
+ let processError = null;
545
+
546
+ const parseEntryLine = (line) => {
547
+ if (!line.trim()) return;
548
+ try {
549
+ entries.push(JSON.parse(line));
550
+ } catch (error) {
551
+ processError = `Could not parse playlist item: ${error.message}`;
552
+ }
553
+ };
554
+
555
+ proc.stdout.on('data', (chunk) => {
556
+ stdoutBuffer += stdoutDecoder.write(chunk);
557
+ const lines = stdoutBuffer.split(/\r?\n/);
558
+ stdoutBuffer = lines.pop();
559
+ lines.forEach(parseEntryLine);
560
+ });
561
+ proc.stderr.on('data', (chunk) => {
562
+ stderrBuffer += stderrDecoder.write(chunk);
563
+ const lines = stderrBuffer.split(/\r?\n/);
564
+ stderrBuffer = lines.pop();
565
+ for (const line of lines) {
566
+ const cleaned = sanitizeLogLine(line);
567
+ if (cleaned) {
568
+ placeholder.logLines.push(cleaned);
569
+ if (placeholder.logLines.length > MAX_LOG_LINES) placeholder.logLines.shift();
570
+ }
571
+ }
572
+ this.onUpdate();
573
+ });
574
+ proc.on('error', (error) => {
575
+ processError = `yt-dlp playlist scan error: ${error.message}`;
576
+ });
577
+ proc.on('close', (code) => {
578
+ stdoutBuffer += stdoutDecoder.end();
579
+ parseEntryLine(stdoutBuffer);
580
+ stderrBuffer += stderrDecoder.end();
581
+ const lastErrorLine = sanitizeLogLine(stderrBuffer);
582
+ if (lastErrorLine) placeholder.logLines.push(lastErrorLine);
583
+ placeholder.proc = null;
584
+
585
+ if (!this.items.includes(placeholder) || placeholder.status === 'Cancelled') return;
586
+ if (code !== 0 || processError || entries.length === 0) {
587
+ placeholder.status = 'Error';
588
+ placeholder.errorMsg =
589
+ processError || lastErrorLine || (entries.length === 0 ? 'Плейлист пуст или не удалось получить список.' : `Exit code ${code}`);
590
+ placeholder.logLines.push(sanitizeLogLine(placeholder.errorMsg));
591
+ this._startNextQueued();
592
+ this.onUpdate();
593
+ return;
594
+ }
595
+
596
+ const expanded = entries
597
+ .map((entry, index) => {
598
+ const entryUrl = playlistEntryUrl(entry, placeholder.url);
599
+ return entryUrl ? this._createQueueItem(opts, entryUrl, null, entry, index) : null;
600
+ })
601
+ .filter(Boolean);
602
+ if (expanded.length === 0) {
603
+ placeholder.status = 'Error';
604
+ placeholder.errorMsg = 'yt-dlp не вернул ссылок на треки плейлиста.';
605
+ placeholder.logLines.push(placeholder.errorMsg);
606
+ this._startNextQueued();
607
+ this.onUpdate();
608
+ return;
609
+ }
610
+
611
+ const index = this.items.indexOf(placeholder);
612
+ if (index === -1) return;
613
+ for (const item of expanded) item.startImmediately = placeholder.startImmediately;
614
+ this.items.splice(index, 1, ...expanded);
615
+ this._startNextQueued();
616
+ this.onUpdate();
617
+ });
618
+ }
619
+
620
+ _startNextQueued() {
621
+ if (this.items.some((item) => item.status === 'Downloading' || item.status === 'Paused')) return;
622
+ const next = this.items.find((item) => item.status === 'Queued' || item.status === 'Preparing');
623
+ if (!next || next.status === 'Preparing' || !next.startImmediately) return;
624
+ this.startItem(next);
625
+ }
626
+
627
+ _retryOrFinish(item, errorMsg) {
628
+ item.errorMsg = errorMsg;
629
+ if (item.retryCount < MAX_DOWNLOAD_RETRIES) {
630
+ item.retryCount += 1;
631
+ item.status = 'Queued';
632
+ item.progress = 0;
633
+ item.speedStr = '';
634
+ item.etaStr = '';
635
+ item.sizeStr = '—';
636
+ item.logLines.push(`Ошибка загрузки. Повтор ${item.retryCount}/${MAX_DOWNLOAD_RETRIES}: ${errorMsg}`);
637
+ const currentIndex = this.items.indexOf(item);
638
+ if (currentIndex !== -1) {
639
+ this.items.splice(currentIndex, 1);
640
+ this.items.push(item);
641
+ }
642
+ } else {
643
+ item.status = 'Error';
644
+ item.logLines.push(`Загрузка не удалась после ${MAX_DOWNLOAD_RETRIES} повторов: ${errorMsg}`);
376
645
  }
377
- this.onUpdate();
378
- return item;
379
646
  }
380
647
 
381
648
  _updateFinalFileSize(item) {
@@ -432,7 +699,7 @@ class DownloadManager {
432
699
  }
433
700
 
434
701
  startItem(item) {
435
- if (item.status === 'Downloading') return;
702
+ if (item.status === 'Downloading' || item.proc) return;
436
703
  item.status = 'Downloading';
437
704
  item.errorMsg = '';
438
705
 
@@ -454,6 +721,7 @@ class DownloadManager {
454
721
  item.logLines.push('Error: yt-dlp binary not found. Install via brew/apt/winget or download standalone binary.');
455
722
  }
456
723
  this.onUpdate();
724
+ this._startNextQueued();
457
725
  return;
458
726
  }
459
727
 
@@ -467,14 +735,31 @@ class DownloadManager {
467
735
  const proc = spawn(this.ytdlpPath, item.cmdArgs, {
468
736
  stdio: ['ignore', 'pipe', 'pipe'],
469
737
  env: childEnv,
738
+ detached: process.platform !== 'win32',
470
739
  });
471
740
  item.proc = proc;
472
741
 
742
+ let updateTimer = null;
743
+ const scheduleUpdate = () => {
744
+ if (updateTimer) return;
745
+ updateTimer = setTimeout(() => {
746
+ updateTimer = null;
747
+ this.onUpdate();
748
+ }, 100);
749
+ };
750
+ const flushUpdate = () => {
751
+ if (updateTimer) {
752
+ clearTimeout(updateTimer);
753
+ updateTimer = null;
754
+ }
755
+ this.onUpdate();
756
+ };
757
+
473
758
  const handleLine = (line) => {
474
- const trimmed = line.trim();
759
+ const trimmed = sanitizeLogLine(line);
475
760
  if (!trimmed) return;
476
761
  item.logLines.push(trimmed);
477
- if (item.logLines.length > 200) item.logLines.shift();
762
+ if (item.logLines.length > MAX_LOG_LINES) item.logLines.shift();
478
763
 
479
764
  const mDest = this.destRe.exec(trimmed) || this.mergerRe.exec(trimmed) || this.alreadyRe.exec(trimmed);
480
765
  if (mDest) {
@@ -503,7 +788,7 @@ class DownloadManager {
503
788
  item.speedStr = mProg[3] || '';
504
789
  item.etaStr = mProg[4] || '';
505
790
  }
506
- this.onUpdate();
791
+ scheduleUpdate();
507
792
  };
508
793
 
509
794
  const stdoutDecoder = new StringDecoder('utf8');
@@ -518,6 +803,14 @@ class DownloadManager {
518
803
  for (const p of parts) handleLine(p);
519
804
  });
520
805
 
806
+ proc.on('error', (error) => {
807
+ item.proc = null;
808
+ item.status = 'Error';
809
+ item.errorMsg = `yt-dlp process error: ${error.message}`;
810
+ item.logLines.push(sanitizeLogLine(item.errorMsg));
811
+ flushUpdate();
812
+ });
813
+
521
814
  proc.stderr.on('data', (chunk) => {
522
815
  stderrBuf += stderrDecoder.write(chunk);
523
816
  const parts = stderrBuf.split(/[\r\n]+/);
@@ -526,6 +819,10 @@ class DownloadManager {
526
819
  });
527
820
 
528
821
  proc.on('close', (code) => {
822
+ if (item.killTimer) {
823
+ clearTimeout(item.killTimer);
824
+ item.killTimer = null;
825
+ }
529
826
  const remOut = stdoutBuf + stdoutDecoder.end();
530
827
  const remErr = stderrBuf + stderrDecoder.end();
531
828
  if (remOut.trim()) handleLine(remOut);
@@ -533,18 +830,20 @@ class DownloadManager {
533
830
 
534
831
  item.proc = null;
535
832
  if (item.status === 'Cancelled' || item.status === 'Paused') {
536
- this.onUpdate();
833
+ flushUpdate();
834
+ this._startNextQueued();
537
835
  return;
538
836
  }
539
837
  if (code === 0) {
540
838
  item.progress = 100;
541
839
  item.status = 'Done';
840
+ item.errorMsg = '';
542
841
  this._updateFinalFileSize(item);
543
842
  } else {
544
- item.status = 'Error';
545
- item.errorMsg = item.logLines[item.logLines.length - 1] || `Exit code ${code}`;
843
+ this._retryOrFinish(item, item.logLines[item.logLines.length - 1] || `Exit code ${code}`);
546
844
  }
547
- this.onUpdate();
845
+ flushUpdate();
846
+ this._startNextQueued();
548
847
  });
549
848
  }
550
849
 
@@ -552,11 +851,14 @@ class DownloadManager {
552
851
  if (item.status !== 'Downloading') return;
553
852
  if (item.proc) {
554
853
  try {
555
- item.proc.kill('SIGSTOP');
556
- item.status = 'Paused';
557
- } catch (_) {
558
- item.proc.kill('SIGTERM');
854
+ if (process.platform === 'win32') {
855
+ setWindowsProcessTreeState(item.proc.pid, 'pause');
856
+ } else {
857
+ process.kill(-item.proc.pid, 'SIGSTOP');
858
+ }
559
859
  item.status = 'Paused';
860
+ } catch (error) {
861
+ item.logLines.push(sanitizeLogLine(`Pause failed: ${error.message}`));
560
862
  }
561
863
  } else if (item.simTimer) {
562
864
  item.status = 'Paused';
@@ -567,35 +869,81 @@ class DownloadManager {
567
869
  resumeItem(item) {
568
870
  if (item.status === 'Paused' && item.proc) {
569
871
  try {
570
- item.proc.kill('SIGCONT');
872
+ if (process.platform === 'win32') {
873
+ setWindowsProcessTreeState(item.proc.pid, 'resume');
874
+ } else {
875
+ process.kill(-item.proc.pid, 'SIGCONT');
876
+ }
571
877
  item.status = 'Downloading';
572
878
  this.onUpdate();
573
879
  return;
574
- } catch (_) {}
880
+ } catch (error) {
881
+ item.logLines.push(sanitizeLogLine(`Resume failed: ${error.message}`));
882
+ this.onUpdate();
883
+ return;
884
+ }
575
885
  }
576
886
  if (item.status === 'Paused' && item.simTimer) {
577
887
  item.status = 'Downloading';
578
888
  this.onUpdate();
579
889
  return;
580
890
  }
581
- if (['Paused', 'Queued', 'Error', 'Cancelled'].includes(item.status)) {
582
- this.startItem(item);
891
+ if (['Queued', 'Error', 'Cancelled'].includes(item.status)) {
892
+ if (item.status === 'Error' || item.status === 'Cancelled') item.retryCount = 0;
893
+ item.status = 'Queued';
894
+ this._startNextQueued();
583
895
  }
584
896
  }
585
897
 
586
898
  cancelItem(item) {
899
+ const waitingForProcessExit = Boolean(item.proc);
587
900
  if (item.simTimer) {
588
901
  clearInterval(item.simTimer);
589
902
  item.simTimer = null;
590
903
  }
591
904
  if (item.proc) {
592
- try {
593
- item.proc.kill('SIGTERM');
594
- } catch (_) {}
595
- item.proc = null;
905
+ const proc = item.proc;
906
+ if (process.platform === 'win32') {
907
+ const killer = spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F'], {
908
+ stdio: 'ignore',
909
+ windowsHide: true,
910
+ });
911
+ killer.on('error', (error) => {
912
+ if (item.proc !== proc) return;
913
+ item.status = 'Error';
914
+ item.errorMsg = `Unable to stop yt-dlp process tree: ${error.message}`;
915
+ item.logLines.push(sanitizeLogLine(item.errorMsg));
916
+ this.onUpdate();
917
+ });
918
+ killer.on('close', (code) => {
919
+ if (item.proc !== proc || proc.exitCode !== null) return;
920
+ if (code !== 0) {
921
+ item.status = 'Error';
922
+ item.errorMsg = `taskkill exited with code ${code}`;
923
+ item.logLines.push(sanitizeLogLine(item.errorMsg));
924
+ this.onUpdate();
925
+ }
926
+ });
927
+ } else {
928
+ try {
929
+ process.kill(-proc.pid, 'SIGTERM');
930
+ item.killTimer = setTimeout(() => {
931
+ if (item.proc !== proc || proc.exitCode !== null) return;
932
+ try {
933
+ process.kill(-proc.pid, 'SIGKILL');
934
+ } catch (error) {
935
+ item.logLines.push(sanitizeLogLine(`Unable to force-stop yt-dlp: ${error.message}`));
936
+ this.onUpdate();
937
+ }
938
+ }, 1500);
939
+ } catch (error) {
940
+ item.logLines.push(sanitizeLogLine(`Unable to stop yt-dlp process tree: ${error.message}`));
941
+ }
942
+ }
596
943
  }
597
944
  item.status = 'Cancelled';
598
945
  this.onUpdate();
946
+ if (!waitingForProcessExit) this._startNextQueued();
599
947
  }
600
948
 
601
949
  removeItem(idx) {
@@ -627,6 +975,7 @@ class DownloadManager {
627
975
  clearInterval(item.simTimer);
628
976
  item.simTimer = null;
629
977
  item.status = 'Done';
978
+ this._startNextQueued();
630
979
  }
631
980
  this.onUpdate();
632
981
  }, 120);
@@ -641,6 +990,7 @@ class ScreenBuffer {
641
990
  constructor(cols, rows) {
642
991
  this.cols = cols;
643
992
  this.rows = rows;
993
+ this.clipRight = null;
644
994
  this.chars = Array.from({ length: rows }, () => Array(cols).fill(' '));
645
995
  this.styles = Array.from({ length: rows }, () => Array(cols).fill(ANSI.fgWhite));
646
996
  }
@@ -650,13 +1000,18 @@ class ScreenBuffer {
650
1000
  const str = String(text);
651
1001
  for (let i = 0; i < str.length; i++) {
652
1002
  const cx = x + i;
653
- if (cx >= 0 && cx < this.cols - 1) {
1003
+ if (cx >= 0 && cx < this.cols - 1 && (this.clipRight === null || cx <= this.clipRight)) {
654
1004
  this.chars[y][cx] = str[i];
655
1005
  this.styles[y][cx] = style;
656
1006
  }
657
1007
  }
658
1008
  }
659
1009
 
1010
+ putStrWithin(y, x, text, width, style = ANSI.fgWhite) {
1011
+ if (width <= 0) return;
1012
+ this.putStr(y, x, String(text).slice(0, width), style);
1013
+ }
1014
+
660
1015
  drawBox(y, x, h, w, title = '', style = ANSI.fgWhite, titleStyle = ANSI.bold + ANSI.fgCyan) {
661
1016
  if (h < 2 || w < 4) return;
662
1017
  this.putStr(y, x, '┌' + '─'.repeat(w - 2) + '┐', style);
@@ -671,7 +1026,7 @@ class ScreenBuffer {
671
1026
  }
672
1027
 
673
1028
  renderToAnsi() {
674
- let out = `${CSI}H`;
1029
+ let out = '';
675
1030
  for (let r = 0; r < this.rows; r++) {
676
1031
  let curStyle = '';
677
1032
  let line = '';
@@ -684,8 +1039,7 @@ class ScreenBuffer {
684
1039
  line += this.chars[r][c];
685
1040
  }
686
1041
  line += ANSI.reset;
687
- if (r < this.rows - 1) line += '\r\n';
688
- out += line;
1042
+ out += `${CSI}${r + 1};1H${CSI}2K${line}`;
689
1043
  }
690
1044
  return out;
691
1045
  }
@@ -702,10 +1056,12 @@ class EvaNodeTUI {
702
1056
  this.focusOrder = [];
703
1057
  this.hitboxes = [];
704
1058
  this.selectedQueueIdx = 0;
1059
+ this.selectedQueueItem = null;
1060
+ this.queueScrollOffset = 0;
1061
+ this.queueViewport = null;
705
1062
  this.statusBanner = '';
706
1063
 
707
- // Inline modal prompt state
708
- this.modal = null; // { label, value, cursor, onConfirm }
1064
+ this.modal = null;
709
1065
  }
710
1066
 
711
1067
  start() {
@@ -730,101 +1086,84 @@ class EvaNodeTUI {
730
1086
  process.exit(0);
731
1087
  }
732
1088
 
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);
1089
+ registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
1090
+ this.focusOrder.push(id);
1091
+ const isFocused = this.focusId === id;
1092
+ let prefix = '';
1093
+
1094
+ if (checked !== null) {
1095
+ prefix = checked ? '[x] ' : '[ ] ';
1096
+ } else if (radio !== null) {
1097
+ prefix = radio ? '(•) ' : '( ) ';
1098
+ }
1099
+
1100
+ let style = ANSI.fgGray;
1101
+ if (isFocused) {
1102
+ style = isCta
1103
+ ? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
1104
+ : ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
1105
+ } else if (isCta) {
1106
+ style = id === 'url_input'
1107
+ ? ANSI.fgWhite + ANSI.bold
1108
+ : id === 'btn_add_queue'
1109
+ ? ANSI.fgGreen + ANSI.bold
1110
+ : ANSI.fgCyan + ANSI.bold;
1111
+ } else if (checked || radio) {
1112
+ style = ANSI.fgOrange + ANSI.bold;
1113
+ }
1114
+
1115
+ if (checked !== null) {
1116
+ scr.putStr(y, x, '[', ANSI.fgGray);
773
1117
  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;
1118
+ scr.putStr(y, x + 2, '] ', ANSI.fgGray);
1119
+ } else if (radio !== null) {
1120
+ scr.putStr(y, x, '(', ANSI.fgGray);
1121
+ scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
1122
+ scr.putStr(y, x + 2, ') ', ANSI.fgGray);
1123
+ }
787
1124
 
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);
1125
+ const availableWidth = maxWidth > 0 ? Math.max(0, maxWidth - prefix.length) : Infinity;
1126
+ let visibleLabel = label;
1127
+ if (visibleLabel.length > availableWidth) {
1128
+ visibleLabel = availableWidth > 0 ? visibleLabel.slice(0, availableWidth - 1) + '…' : '';
1129
+ }
1130
+
1131
+ const textX = x + prefix.length;
1132
+ const editMarker = '[ред.]';
1133
+ const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
1134
+ if (!isFocused && editMarkerIndex >= 0) {
1135
+ const folderLabel = 'Папка:';
1136
+ const folderLabelIndex = visibleLabel.indexOf(folderLabel);
1137
+ if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
1138
+ scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
1139
+ scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
1140
+ const afterFolderIndex = folderLabelIndex + folderLabel.length;
1141
+ scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
1142
+ } else {
1143
+ scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1144
+ }
1145
+ scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
1146
+ scr.putStr(
1147
+ y,
1148
+ textX + editMarkerIndex + editMarker.length,
1149
+ visibleLabel.slice(editMarkerIndex + editMarker.length),
1150
+ style
1151
+ );
806
1152
  } else {
807
- scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1153
+ scr.putStr(y, textX, visibleLabel, style);
808
1154
  }
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);
1155
+
1156
+ const totalLength = prefix.length + visibleLabel.length;
1157
+ this.hitboxes.push({
1158
+ id,
1159
+ y,
1160
+ x1: x,
1161
+ x2: Math.min(x + totalLength - 1, scr.clipRight === null ? scr.cols - 2 : scr.clipRight),
1162
+ action,
1163
+ });
1164
+
1165
+ return x + totalLength;
813
1166
  }
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
1167
 
829
1168
 
830
1169
  openPrompt(label, currentVal, onConfirm) {
@@ -832,7 +1171,6 @@ class EvaNodeTUI {
832
1171
  this.modal = {
833
1172
  label,
834
1173
  value: val,
835
- cursor: val.length,
836
1174
  onConfirm,
837
1175
  };
838
1176
  this.render();
@@ -843,12 +1181,19 @@ class EvaNodeTUI {
843
1181
  // -------------------------------------------------------------------------
844
1182
 
845
1183
  render() {
1184
+ if (this.selectedQueueItem) {
1185
+ const selectedIndex = this.manager.items.indexOf(this.selectedQueueItem);
1186
+ if (selectedIndex >= 0) this.selectedQueueIdx = selectedIndex;
1187
+ else this.selectedQueueItem = null;
1188
+ }
1189
+
846
1190
  const cols = process.stdout.columns || 100;
847
1191
  const rows = process.stdout.rows || 28;
848
1192
  const scr = new ScreenBuffer(cols, rows);
849
1193
 
850
1194
  this.focusOrder = [];
851
1195
  this.hitboxes = [];
1196
+ this.queueViewport = null;
852
1197
 
853
1198
  if (rows < 22 || cols < 76) {
854
1199
  scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
@@ -856,19 +1201,32 @@ class EvaNodeTUI {
856
1201
  return;
857
1202
  }
858
1203
 
859
- // 1. Header Logo & Tagline
1204
+ this._renderHeader(scr, cols);
1205
+ const { x, w } = this._renderNavigation(scr, rows, cols);
1206
+ this._renderActiveTab(scr, x, w, rows);
1207
+
1208
+ if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
1209
+ this.focusId = this.focusOrder[0];
1210
+ }
1211
+
1212
+ this._renderFooter(scr, cols, rows);
1213
+ process.stdout.write(scr.renderToAnsi());
1214
+ }
1215
+
1216
+ _renderHeader(scr, cols) {
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.';
867
1224
  scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
868
1225
  scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
869
1226
  }
1227
+ }
870
1228
 
871
- // 2. Left Sidebar Navigation (Strictly 1-cell narrow CP437/WGL4 characters so borders NEVER shift)
1229
+ _renderNavigation(scr, rows, cols) {
872
1230
  const sidebarW = 19;
873
1231
  const navItems = [
874
1232
  { tab: 1, label: '≡ General' },
@@ -887,6 +1245,7 @@ class EvaNodeTUI {
887
1245
  this.hitboxes.push({
888
1246
  id: `tab_${nav.tab}`,
889
1247
  y: r,
1248
+ nav: true,
890
1249
  x1: 2,
891
1250
  x2: 2 + padded.length - 1,
892
1251
  action: () => {
@@ -900,32 +1259,31 @@ class EvaNodeTUI {
900
1259
  scr.putStr(r, sidebarW + 1, '│', ANSI.fgWhite);
901
1260
  }
902
1261
 
903
- // 3. Main Content Area
904
1262
  const mainX = sidebarW + 3;
905
1263
  const mainW = cols - mainX - 2;
1264
+ return { x: mainX, w: mainW };
1265
+ }
906
1266
 
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
- }
1267
+ _renderActiveTab(scr, x, w, rows) {
1268
+ if (this.activeTab === 1) this._renderDownloadTab(scr, x, w, rows);
1269
+ else if (this.activeTab === 2) this._renderQueueTab(scr, x, w, rows);
1270
+ else if (this.activeTab === 3) this._renderLibraryTab(scr, x, w, rows);
1271
+ else if (this.activeTab === 4) this._renderSettingsTab(scr, x, w, rows);
1272
+ else if (this.activeTab === 5) this._renderAboutTab(scr, x, w, rows);
1273
+ }
916
1274
 
917
- // 4. Bottom Status Bar with Live Queue Counter & Progress Bar (or Modal Input)
1275
+ _renderFooter(scr, cols, rows) {
918
1276
  if (this.modal) {
919
1277
  const prefix = ` * ${this.modal.label}: `;
920
1278
  scr.putStr(rows - 2, 0, prefix, ANSI.bgCyan + ANSI.fgBlack + ANSI.bold);
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;
928
- const activeItems = items.filter((i) => i.status === 'Downloading');
1286
+ const activeItems = items.filter((item) => item.status === 'Downloading');
929
1287
  const doneCnt = items.filter((i) => i.status === 'Done').length;
930
1288
 
931
1289
  let pct = 0;
@@ -936,22 +1294,16 @@ class EvaNodeTUI {
936
1294
  pct = 100;
937
1295
  }
938
1296
 
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);
1297
+ const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
1298
+ const queueStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
1299
+ const queueStartX = cols - queueStatus.length;
1300
+ scr.putStr(rows - 1, queueStartX, queueStatus, queueStyle);
947
1301
 
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);
1302
+ const statusX = queueStartX + queueStatus.length + 2;
1303
+ if (this.statusBanner && statusX < cols - 10) {
1304
+ scr.putStr(rows - 1, statusX, `│ ${this.statusBanner}`.slice(0, cols - statusX - 2), ANSI.fgCyan);
951
1305
  }
952
1306
  }
953
-
954
- process.stdout.write(scr.renderToAnsi());
955
1307
  }
956
1308
 
957
1309
  // -------------------------------------------------------------------------
@@ -960,9 +1312,11 @@ class EvaNodeTUI {
960
1312
 
961
1313
  _renderDownloadTab(scr, x, w, rows) {
962
1314
  let y = 8;
1315
+ const previousClipRight = scr.clipRight;
963
1316
 
964
1317
  // --- Box 1: Select a source (1-9) & URL input ---
965
1318
  scr.drawBox(y, x, 7, w, 'Select a source');
1319
+ scr.clipRight = x + w - 2;
966
1320
  const colWidth = Math.floor(w / 3);
967
1321
  const col1X = x + 2;
968
1322
  const col2X = x + colWidth;
@@ -986,7 +1340,9 @@ class EvaNodeTUI {
986
1340
  );
987
1341
  });
988
1342
 
1343
+ scr.clipRight = previousClipRight;
989
1344
  scr.putStr(y + 4, x + 0, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1345
+ scr.clipRight = x + w - 2;
990
1346
  const srcObj = SOURCES.find((s) => s.id === this.opts.sourceId) || SOURCES[0];
991
1347
  const urlDisp = this.opts.url ? this.opts.url : `(нажмите 'u' или кликните для ввода URL — ${srcObj.hint})`;
992
1348
  this.registerWidget(
@@ -1003,7 +1359,9 @@ class EvaNodeTUI {
1003
1359
 
1004
1360
  // --- Box 2: Download Options & yt-dlp Flags ---
1005
1361
  const box2H = 11;
1362
+ scr.clipRight = previousClipRight;
1006
1363
  scr.drawBox(y, x, box2H, w, 'Download Options Flags');
1364
+ scr.clipRight = x + w - 2;
1007
1365
  const c1 = x + 2;
1008
1366
 
1009
1367
  // Row 1: Audio (-x, metadata, thumbnail)
@@ -1114,43 +1472,47 @@ class EvaNodeTUI {
1114
1472
 
1115
1473
  // Row 6: Naming & Archive (-o & --download-archive)
1116
1474
  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;
1475
+ const rowRight = x + w - 2;
1476
+ const templateX = c1 + 9;
1477
+ const rowWidth = rowRight - templateX + 1;
1478
+ const templateMaxWidth = Math.max(4, 4 + Math.floor(Math.max(0, rowWidth - 24) / 2));
1479
+ const templateEnd = this.registerWidget(
1480
+ scr,
1481
+ 'flag_otpl',
1482
+ y + 6,
1483
+ templateX,
1484
+ `-o "${this.opts.outputTemplate}"`,
1485
+ () => this._toggle('usePlaylistTemplate'),
1486
+ {
1487
+ checked: this.opts.usePlaylistTemplate,
1488
+ maxWidth: templateMaxWidth,
1489
+ }
1490
+ );
1491
+ const templateEditEnd = this.registerWidget(scr, 'edit_otpl', y + 6, templateEnd + 1, '[ред.]', () => {
1492
+ return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1493
+ this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1494
+ });
1495
+ }, { maxWidth: 6 });
1138
1496
 
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
- })
1497
+ const archiveX = templateEditEnd + 2;
1498
+ const archiveMaxWidth = Math.max(4, rowRight - archiveX - '[ред.]'.length);
1499
+ const archiveEnd = this.registerWidget(
1500
+ scr,
1501
+ 'flag_archive',
1502
+ y + 6,
1503
+ archiveX,
1504
+ `История загрузки ${this.opts.archiveFile}`,
1505
+ () => this._toggle('useDownloadArchive'),
1506
+ {
1507
+ checked: this.opts.useDownloadArchive,
1508
+ maxWidth: archiveMaxWidth,
1509
+ }
1153
1510
  );
1511
+ this.registerWidget(scr, 'edit_archive', y + 6, archiveEnd + 1, '[ред.]', () => {
1512
+ return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1513
+ this.opts.archiveFile = v || 'archive.txt';
1514
+ });
1515
+ }, { maxWidth: 6 });
1154
1516
 
1155
1517
  // Row 7: Playlists (--playlist-items & --playlist-reverse)
1156
1518
  scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
@@ -1201,6 +1563,7 @@ class EvaNodeTUI {
1201
1563
  });
1202
1564
 
1203
1565
  y += box2H;
1566
+ scr.clipRight = previousClipRight;
1204
1567
 
1205
1568
  // Word-wrapped CLI Preview Lines (never overflows the window width)
1206
1569
  const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
@@ -1217,21 +1580,39 @@ class EvaNodeTUI {
1217
1580
 
1218
1581
  _renderQueueBox(scr, y, x, h, w) {
1219
1582
  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);
1583
+ const contentWidth = w - 4;
1584
+ const titleX = 4;
1585
+ const statusColW = Math.max(1, Math.min(29, Math.floor(contentWidth * 0.28), contentWidth - 26));
1586
+ const statusX = contentWidth - statusColW - 1;
1587
+ const sizeX = statusX - 1 - 10;
1588
+ const formatX = sizeX - 1 - 7;
1589
+ const titleColW = Math.max(0, formatX - titleX - 1);
1590
+ scr.putStr(y + 1, x + 2 + titleX, 'Title'.slice(0, titleColW), ANSI.fgCyan + ANSI.bold);
1591
+ scr.putStr(y + 1, x + 2 + formatX, 'Format', ANSI.fgCyan + ANSI.bold);
1592
+ scr.putStr(y + 1, x + 2 + sizeX, 'Size', ANSI.fgCyan + ANSI.bold);
1593
+ scr.putStr(y + 1, x + 2 + statusX, 'Status', ANSI.fgCyan + ANSI.bold);
1594
+ scr.putStr(y + 2, x, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1224
1595
 
1225
1596
  const items = this.manager.items;
1226
1597
  if (this.selectedQueueIdx >= items.length) {
1227
1598
  this.selectedQueueIdx = Math.max(0, items.length - 1);
1228
1599
  }
1600
+ this.selectedQueueItem = items[this.selectedQueueIdx] || null;
1601
+
1602
+ const listRows = Math.max(1, h - 6);
1603
+ this.queueViewport = { x, right: x + w, top: y + 3, rows: listRows };
1604
+ const maxScrollOffset = Math.max(0, items.length - listRows);
1605
+ this.queueScrollOffset = Math.max(0, Math.min(this.queueScrollOffset, maxScrollOffset));
1606
+ if (this.selectedQueueIdx < this.queueScrollOffset) {
1607
+ this.queueScrollOffset = this.selectedQueueIdx;
1608
+ } else if (this.selectedQueueIdx >= this.queueScrollOffset + listRows) {
1609
+ this.queueScrollOffset = this.selectedQueueIdx - listRows + 1;
1610
+ }
1229
1611
 
1230
- const listRows = Math.max(1, h - 5);
1231
1612
  if (items.length === 0) {
1232
1613
  scr.putStr(y + 3, x + 4, 'Очередь пуста. Добавьте трек во вкладке [1] Download.', ANSI.fgWhite);
1233
1614
  } else {
1234
- const startI = Math.max(0, this.selectedQueueIdx - listRows + 1);
1615
+ const startI = this.queueScrollOffset;
1235
1616
  const slice = items.slice(startI, startI + listRows);
1236
1617
  slice.forEach((it, rowOffset) => {
1237
1618
  const idx = startI + rowOffset;
@@ -1242,15 +1623,18 @@ class EvaNodeTUI {
1242
1623
  const filled = Math.round((it.progress / 100) * barW);
1243
1624
  const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
1244
1625
  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)}`;
1626
+ let statusText;
1627
+ if (it.status === 'Done') {
1628
+ statusText = '✓ Done';
1629
+ } else if (it.status === 'Error') {
1630
+ statusText = '✗ Error';
1631
+ } else {
1632
+ statusText = `[${bar}] ${pct} ${it.status}`;
1633
+ if (it.retryCount > 0) statusText += ` (${it.retryCount}/${MAX_DOWNLOAD_RETRIES})`;
1248
1634
  }
1635
+ statusText = statusText.slice(0, statusColW).padEnd(statusColW);
1249
1636
 
1250
1637
  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
1638
  const st = isSel
1255
1639
  ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold
1256
1640
  : it.status === 'Done'
@@ -1259,7 +1643,12 @@ class EvaNodeTUI {
1259
1643
  ? ANSI.fgRed
1260
1644
  : ANSI.fgWhite;
1261
1645
 
1262
- scr.putStr(ry, x + 2, line.slice(0, w - 4), st);
1646
+ scr.putStr(ry, x + 2, `${pointer}${String(seqNum).padEnd(2)} `, st);
1647
+ const title = fitTextToCellWidth(it.title, titleColW);
1648
+ scr.putStrWithin(ry, x + 2 + titleX, title, titleColW, st);
1649
+ scr.putStr(ry, x + 2 + formatX, it.fmt.slice(0, 7).padEnd(7), st);
1650
+ scr.putStr(ry, x + 2 + sizeX, it.sizeStr.slice(0, 10).padEnd(10), st);
1651
+ scr.putStr(ry, x + 2 + statusX, statusText, st);
1263
1652
  this.hitboxes.push({
1264
1653
  id: `qitem_${idx}`,
1265
1654
  y: ry,
@@ -1267,21 +1656,53 @@ class EvaNodeTUI {
1267
1656
  x2: x + w - 3,
1268
1657
  action: () => {
1269
1658
  this.selectedQueueIdx = idx;
1659
+ this.selectedQueueItem = it;
1270
1660
  this.render();
1271
1661
  },
1272
1662
  });
1273
1663
  });
1664
+
1665
+ if (items.length > listRows) {
1666
+ const scrollbarX = x + w - 2;
1667
+ const thumbSize = Math.max(1, Math.floor((listRows * listRows) / items.length));
1668
+ const thumbTravel = listRows - thumbSize;
1669
+ const thumbTop = maxScrollOffset > 0
1670
+ ? Math.round((this.queueScrollOffset / maxScrollOffset) * thumbTravel)
1671
+ : 0;
1672
+ for (let rowOffset = 0; rowOffset < listRows; rowOffset++) {
1673
+ const isThumb = rowOffset >= thumbTop && rowOffset < thumbTop + thumbSize;
1674
+ const scrollbarY = y + 3 + rowOffset;
1675
+ scr.putStr(scrollbarY, scrollbarX, isThumb ? '█' : '│', isThumb ? ANSI.fgCyan : ANSI.fgGray);
1676
+ this.hitboxes.push({
1677
+ id: 'q_scroll',
1678
+ y: scrollbarY,
1679
+ x1: scrollbarX,
1680
+ x2: scrollbarX,
1681
+ action: () => {
1682
+ const nextThumbTop = Math.max(0, Math.min(thumbTravel, rowOffset - Math.floor(thumbSize / 2)));
1683
+ this.queueScrollOffset = thumbTravel > 0
1684
+ ? Math.round((nextThumbTop / thumbTravel) * maxScrollOffset)
1685
+ : 0;
1686
+ this.selectedQueueIdx = this.queueScrollOffset;
1687
+ this.selectedQueueItem = items[this.selectedQueueIdx] || null;
1688
+ this.render();
1689
+ },
1690
+ });
1691
+ }
1692
+ }
1274
1693
  }
1275
1694
 
1276
1695
  const ctrlY = y + h - 2;
1277
- scr.putStr(ctrlY - 1, x + 1, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1696
+ scr.putStr(ctrlY - 1, x, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1278
1697
  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;
1698
+ cx = this.registerWidget(scr, 'q_resume', ctrlY, cx, '[r]Resume', () => this._qAction('resume')) + 1;
1699
+ cx = this.registerWidget(scr, 'q_pause', ctrlY, cx, '[p]Pause', () => this._qAction('pause')) + 1;
1700
+ cx = this.registerWidget(scr, 'q_cancel', ctrlY, cx, '[c]Cancel', () => this._qAction('cancel')) + 1;
1701
+ cx = this.registerWidget(scr, 'q_remove', ctrlY, cx, '[d]Remove', () => this._qAction('remove')) + 1;
1283
1702
  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);
1703
+ const activeLabel = `│ Active: ${activeCnt}/${items.length}`;
1704
+ const activeX = x + w - activeLabel.length - 2;
1705
+ if (activeX > cx) scr.putStr(ctrlY, activeX, activeLabel, ANSI.fgCyan);
1285
1706
  }
1286
1707
 
1287
1708
  _renderQueueTab(scr, x, w, rows) {
@@ -1309,7 +1730,7 @@ class EvaNodeTUI {
1309
1730
  _renderLibraryTab(scr, x, w, rows) {
1310
1731
  const h = rows - 9;
1311
1732
  scr.drawBox(8, x, h, w, `Downloaded Media Library (${this.opts.outputDir})`);
1312
- const outDir = this.opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
1733
+ const outDir = resolveOutputDir(this.opts.outputDir);
1313
1734
  const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
1314
1735
  let files = [];
1315
1736
  try {
@@ -1326,22 +1747,29 @@ class EvaNodeTUI {
1326
1747
  }
1327
1748
  } catch (_) {}
1328
1749
 
1329
- const nameColW = Math.max(20, w - 28);
1750
+ const contentWidth = w - 6;
1751
+ const numberColW = 4;
1752
+ const formatColW = 7;
1753
+ const sizeColW = 10;
1754
+ const nameColW = Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5);
1755
+ const formatX = numberColW + 1 + nameColW + 1;
1756
+ const sizeX = formatX + formatColW + 1;
1330
1757
  if (files.length === 0) {
1331
1758
  scr.putStr(10, x + 3, `В папке ${outDir} пока нет скачанных медиафайлов.`.slice(0, w - 6), ANSI.fgWhite);
1332
1759
  } 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) => {
1760
+ scr.putStr(10, x + 3, '#', ANSI.fgCyan + ANSI.bold);
1761
+ scr.putStr(10, x + 3 + numberColW + 1, 'Имя файла', ANSI.fgCyan + ANSI.bold);
1762
+ scr.putStr(10, x + 3 + formatX, 'Формат', ANSI.fgCyan + ANSI.bold);
1763
+ scr.putStr(10, x + 3 + sizeX, 'Размер', ANSI.fgCyan + ANSI.bold);
1764
+ const visibleFileCount = Math.max(0, Math.min(files.length, rows - 15));
1765
+ files.slice(0, visibleFileCount).forEach((f, i) => {
1340
1766
  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);
1767
+ const row = 12 + i;
1768
+ const filename = fitTextToCellWidth(f.name, nameColW);
1769
+ scr.putStr(row, x + 3, String(i + 1).padEnd(numberColW), ANSI.fgWhite);
1770
+ scr.putStrWithin(row, x + 3 + numberColW + 1, filename, nameColW, ANSI.fgWhite);
1771
+ scr.putStrWithin(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), formatColW, ANSI.fgWhite);
1772
+ scr.putStrWithin(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), sizeColW, ANSI.fgWhite);
1345
1773
  });
1346
1774
  }
1347
1775
  }
@@ -1482,6 +1910,9 @@ class EvaNodeTUI {
1482
1910
  ' • Файлы: -o "%(playlist_index)s. %(title)s.%(ext)s", --download-archive archive.txt',
1483
1911
  ' • Плейлисты: --playlist-items 1-5, --playlist-reverse',
1484
1912
  ' • Сеть (вкладка Settings): -4, --proxy http://127.0.0.1:8080, --cookies-from-browser chrome',
1913
+ ` `,
1914
+ ` `,
1915
+ `by .maxon `,
1485
1916
  ];
1486
1917
  lines.forEach((line, i) => {
1487
1918
  if (10 + i < rows - 2) scr.putStr(10 + i, x + 3, line.slice(0, w - 6), ANSI.fgWhite);
@@ -1531,6 +1962,7 @@ class EvaNodeTUI {
1531
1962
  if (this.opts.url.trim()) {
1532
1963
  const it = this.manager.addTask(this.opts, true);
1533
1964
  this.selectedQueueIdx = this.manager.items.length - 1;
1965
+ this.selectedQueueItem = it;
1534
1966
  this.statusBanner = `+ #${this.manager.items.length} (${it.sourceName})`;
1535
1967
  this.render();
1536
1968
  }
@@ -1539,6 +1971,7 @@ class EvaNodeTUI {
1539
1971
  }
1540
1972
  const item = this.manager.addTask(this.opts, true);
1541
1973
  this.selectedQueueIdx = this.manager.items.length - 1;
1974
+ this.selectedQueueItem = item;
1542
1975
  this.statusBanner = `+ #${this.manager.items.length} (${item.sourceName})`;
1543
1976
  this.render();
1544
1977
  }
@@ -1549,7 +1982,10 @@ class EvaNodeTUI {
1549
1982
  if (type === 'resume') this.manager.resumeItem(it);
1550
1983
  else if (type === 'pause') this.manager.pauseItem(it);
1551
1984
  else if (type === 'cancel') this.manager.cancelItem(it);
1552
- else if (type === 'remove') this.manager.removeItem(this.selectedQueueIdx);
1985
+ else if (type === 'remove') {
1986
+ this.manager.removeItem(this.selectedQueueIdx);
1987
+ this.selectedQueueItem = null;
1988
+ }
1553
1989
  this.render();
1554
1990
  }
1555
1991
 
@@ -1570,7 +2006,6 @@ class EvaNodeTUI {
1570
2006
  }
1571
2007
  if (data === '\x15') {
1572
2008
  this.modal.value = '';
1573
- this.modal.cursor = 0;
1574
2009
  this.render();
1575
2010
  return;
1576
2011
  }
@@ -1595,10 +2030,24 @@ class EvaNodeTUI {
1595
2030
  const mx = parseInt(mouseMatch[2], 10) - 1;
1596
2031
  const my = parseInt(mouseMatch[3], 10) - 1;
1597
2032
  const isPress = mouseMatch[4] === 'M';
2033
+ if (this.activeTab === 2 && (btn === 64 || btn === 65)) {
2034
+ const viewport = this.queueViewport;
2035
+ if (viewport && mx >= viewport.x && mx < viewport.right &&
2036
+ my >= viewport.top && my < viewport.top + viewport.rows) {
2037
+ const maxOffset = Math.max(0, this.manager.items.length - viewport.rows);
2038
+ this.queueScrollOffset = Math.max(0, Math.min(maxOffset, this.queueScrollOffset + (btn === 64 ? -1 : 1)));
2039
+ this.selectedQueueIdx = this.queueScrollOffset;
2040
+ this.selectedQueueItem = this.manager.items[this.selectedQueueIdx] || null;
2041
+ this.render();
2042
+ }
2043
+ return;
2044
+ }
1598
2045
  if (isPress && btn === 0) {
1599
- const hit = this.hitboxes.find((h) => h.y === my && mx >= h.x1 && mx <= h.x2);
2046
+ const hit = this.hitboxes.find((h) =>
2047
+ mx >= h.x1 && mx <= h.x2 && (h.y === my || (h.nav && Math.abs(h.y - my) === 1))
2048
+ );
1600
2049
  if (hit) {
1601
- this.focusId = hit.id;
2050
+ if (hit.id !== 'q_scroll') this.focusId = hit.id;
1602
2051
  hit.action();
1603
2052
  }
1604
2053
  }
@@ -1740,9 +2189,14 @@ if (require.main === module) {
1740
2189
  module.exports = {
1741
2190
  createDefaultOptions,
1742
2191
  buildYtdlpArgs,
2192
+ buildPlaylistDiscoveryArgs,
1743
2193
  buildCommandPreview,
1744
2194
  detectSource,
2195
+ isYouTubePlaylistUrl,
2196
+ playlistEntryUrl,
2197
+ ScreenBuffer,
1745
2198
  DownloadManager,
1746
2199
  formatBytesToMB,
2200
+ fitTextToCellWidth,
1747
2201
  wrapTextByWords,
1748
2202
  };