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