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.
- package/eva.js +684 -230
- 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
|
+
* 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.
|
|
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
|
|
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
|
|
351
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
759
|
+
const trimmed = sanitizeLogLine(line);
|
|
475
760
|
if (!trimmed) return;
|
|
476
761
|
item.logLines.push(trimmed);
|
|
477
|
-
if (item.logLines.length >
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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 (['
|
|
582
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
|
1153
|
+
scr.putStr(y, textX, visibleLabel, style);
|
|
808
1154
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
else if (this.activeTab ===
|
|
910
|
-
else if (this.activeTab ===
|
|
911
|
-
else if (this.activeTab ===
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
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
|
|
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((
|
|
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
|
|
940
|
-
const
|
|
941
|
-
const
|
|
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
|
|
949
|
-
if (this.statusBanner &&
|
|
950
|
-
scr.putStr(rows - 1,
|
|
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
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
|
|
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 =
|
|
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
|
|
1246
|
-
if (it.status === '
|
|
1247
|
-
statusText =
|
|
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,
|
|
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
|
|
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]
|
|
1280
|
-
cx = this.registerWidget(scr, 'q_pause', ctrlY, cx, '[p]
|
|
1281
|
-
cx = this.registerWidget(scr, 'q_cancel', ctrlY, cx, '[c]
|
|
1282
|
-
cx = this.registerWidget(scr, 'q_remove', ctrlY, cx, '[d]
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
scr.
|
|
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')
|
|
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) =>
|
|
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
|
};
|