eva-md 0.5.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eva.js +461 -212
- package/package.json +3 -3
package/eva.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* EVA.md v0.
|
|
3
|
+
* EVA.md v0.7.3 — EVA Media Downloader by .maxon
|
|
4
4
|
* Pure Node.js Interactive TUI (Zero external npm dependencies!)
|
|
5
5
|
* Works just like Gemini CLI (`node eva.js` or `./eva.js`), with full mouse & keyboard support.
|
|
6
6
|
*
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
* --write-subs Download subtitles file
|
|
23
23
|
* --embed-subs Embed subtitles into media container
|
|
24
24
|
* --write-thumbnail Save thumbnail as a separate image file
|
|
25
|
-
* File Naming
|
|
25
|
+
* File Naming (Именование файлов — во вкладке Settings):
|
|
26
26
|
* -o "<template>" Output template (e.g. "%(playlist_index)s. %(title)s.%(ext)s")
|
|
27
|
-
* --download-archive <file> Record downloaded track IDs in archive.txt
|
|
28
27
|
* Playlists (Плейлисты):
|
|
29
28
|
* --playlist-items <range> Download specific items (e.g. "1-5")
|
|
30
29
|
* --playlist-reverse Download playlist in reverse order
|
|
30
|
+
* --download-archive <file> Record downloaded track IDs in archive.txt
|
|
31
31
|
* Network & Bypass (Сеть и обход блокировок — во вкладке Settings):
|
|
32
32
|
* -4 Force IPv4 connection (fixes DNS issues)
|
|
33
33
|
* --proxy <url> Use HTTP/SOCKS proxy (e.g. http://127.0.0.1:8080)
|
|
@@ -46,11 +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.7.3 (Node.js)';
|
|
50
50
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'eva_md', 'config.json');
|
|
51
51
|
const MAX_LOG_LINES = 200;
|
|
52
52
|
const MAX_LOG_LINE_LENGTH = 500;
|
|
53
53
|
const MAX_DOWNLOAD_RETRIES = 3;
|
|
54
|
+
const LIBRARY_DOUBLE_CLICK_MS = 400;
|
|
54
55
|
const ANSI_ESCAPE_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
|
|
55
56
|
|
|
56
57
|
// Strictly 1-cell narrow glyphs (CP437/WGL4 safe) so Windows Terminal / PowerShell NEVER shifts borders!
|
|
@@ -78,18 +79,25 @@ const BROWSERS = ['none', 'chrome', 'firefox', 'edge', 'brave', 'opera', 'vivald
|
|
|
78
79
|
const NAMING_PRESETS = [
|
|
79
80
|
{ label: 'Плейлист: 01. Название файла.mp4(формат)', tpl: '%(playlist_index)s. %(title)s.%(ext)s' },
|
|
80
81
|
{ label: 'Стандарт: Название файла.mp4(формат)', tpl: '%(title)s.%(ext)s' },
|
|
81
|
-
{ label: '
|
|
82
|
+
{ label: 'Автор: Автор - Название.mp4(формат)', tpl: '%(uploader)s - %(title)s.%(ext)s' },
|
|
82
83
|
];
|
|
83
84
|
|
|
84
85
|
const ASCII_LOGO = [
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
'
|
|
89
|
-
|
|
90
|
-
'
|
|
86
|
+
' :::::::::: ::: ::: ::: ⡌⣔⣥⣔⣪⣯⣿⣿⣮⣳⣾⣻⣾⣽⢮⣷⢿⣦',
|
|
87
|
+
' :+: :+: :+: :+: :+: ⠨⣔⢺⢿⡿⠟⠛⠋⠩⠥⠄⠀⠌⠉⠝⠻⠿⣿⣿⣷⣿⣿⣳',
|
|
88
|
+
' +:+ +:+ +:+ +:+ +:+ ⢳⣭⢯⡆⠁⠠⠀⢀⣠⣤⠀⠀⠈⠀⠀⠈⡑⢬⢻⣿⣿⣿⣿⣧',
|
|
89
|
+
' +#++:++# +#+ +:+ +#++:++#++: ⠈⠲⣽⣻⣽⢦⠀⠠⠝⣿⣷⡀⠀⠶⠂⠀⣸⣿⣟⠕⢼⣿⣿⣿⢿',
|
|
90
|
+
" +#+ +#+ +#+ +#+ +#+ ⢣⣼⣻⣿⣟⢇⠀⡜⠤⠻⢿⣄⣀⣀⠼⢟⡻⣃⠀⠘⣿⣿⠟",
|
|
91
|
+
' #+# #+#+#+# #+# #+# ⠐⢮⡿⣽⣿⣿⣞⡵⢢⣌⣦⣕⣢⣬⣧⣺⣿⣿⣿⣿⠝',
|
|
92
|
+
"########## ## ### ### ⠲⠕⠻⢿⣿⡻⣿⢯⢟⡿⢯⠟⠛⠻⠝⠋",
|
|
91
93
|
];
|
|
92
94
|
|
|
95
|
+
const STRETCHED_ASCII_LOGO = ASCII_LOGO.map((line) => {
|
|
96
|
+
const chars = Array.from(line);
|
|
97
|
+
const width = Math.round(chars.length * 1.2);
|
|
98
|
+
return Array.from({ length: width }, (_, index) => chars[Math.floor(index / 1.2)]).join('');
|
|
99
|
+
});
|
|
100
|
+
|
|
93
101
|
// ANSI Styling Helpers
|
|
94
102
|
const CSI = '\x1b[';
|
|
95
103
|
const ANSI = {
|
|
@@ -151,6 +159,16 @@ function formatBytesToMB(bytes) {
|
|
|
151
159
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
function formatEta(eta) {
|
|
163
|
+
const parts = String(eta || '').split(':');
|
|
164
|
+
if (!parts.length || parts.length > 3 || parts.some((part) => !/^\d+$/.test(part))) return '00:00';
|
|
165
|
+
|
|
166
|
+
const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);
|
|
167
|
+
const minutes = Math.floor(seconds / 60);
|
|
168
|
+
const remainingSeconds = seconds % 60;
|
|
169
|
+
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
function sanitizeLogLine(line) {
|
|
155
173
|
return String(line)
|
|
156
174
|
.replace(ANSI_ESCAPE_RE, '')
|
|
@@ -343,15 +361,11 @@ function buildYtdlpArgs(opts, targetUrl = null) {
|
|
|
343
361
|
if (opts.embedSubs) args.push('--embed-subs');
|
|
344
362
|
if (opts.writeThumbnail) args.push('--write-thumbnail');
|
|
345
363
|
|
|
346
|
-
// File Naming
|
|
364
|
+
// File Naming
|
|
347
365
|
const outDir = resolveOutputDir(opts.outputDir);
|
|
348
366
|
const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
|
|
349
367
|
args.push('-o', path.join(outDir, tpl));
|
|
350
368
|
|
|
351
|
-
if (opts.useDownloadArchive && opts.archiveFile.trim()) {
|
|
352
|
-
args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
|
|
353
|
-
}
|
|
354
|
-
|
|
355
369
|
// Playlists
|
|
356
370
|
if (opts.usePlaylistItems && opts.playlistItems.trim()) {
|
|
357
371
|
args.push('--playlist-items', opts.playlistItems.trim());
|
|
@@ -359,6 +373,9 @@ function buildYtdlpArgs(opts, targetUrl = null) {
|
|
|
359
373
|
if (opts.playlistReverse) {
|
|
360
374
|
args.push('--playlist-reverse');
|
|
361
375
|
}
|
|
376
|
+
if (opts.useDownloadArchive && opts.archiveFile.trim()) {
|
|
377
|
+
args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
|
|
378
|
+
}
|
|
362
379
|
|
|
363
380
|
// Network & Bypass
|
|
364
381
|
if (opts.forceIpv4) {
|
|
@@ -404,6 +421,71 @@ function detectSource(url, defaultId = 1) {
|
|
|
404
421
|
return { id: found.id, name: found.name };
|
|
405
422
|
}
|
|
406
423
|
|
|
424
|
+
function isVideoUrl(url) {
|
|
425
|
+
try {
|
|
426
|
+
const parsed = new URL(url);
|
|
427
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
|
428
|
+
const pathname = parsed.pathname.toLowerCase();
|
|
429
|
+
|
|
430
|
+
if (/\.(mp4|m4v|mkv|mov|webm|avi)$/i.test(pathname)) return true;
|
|
431
|
+
if (host === 'youtu.be') return pathname.length > 1;
|
|
432
|
+
if (host === 'youtube.com' || host.endsWith('.youtube.com')) {
|
|
433
|
+
if (host === 'music.youtube.com') return false;
|
|
434
|
+
return (
|
|
435
|
+
(pathname === '/watch' && parsed.searchParams.has('v')) ||
|
|
436
|
+
/^\/(shorts|live|embed|v)\//.test(pathname)
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (host === 'vkvideo.ru') return /^\/video/.test(pathname);
|
|
440
|
+
if (host === 'vk.com') return /^\/video/.test(pathname) || pathname === '/video_ext.php';
|
|
441
|
+
return false;
|
|
442
|
+
} catch (_) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isAudioUrl(url) {
|
|
448
|
+
try {
|
|
449
|
+
const parsed = new URL(url);
|
|
450
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
|
451
|
+
const pathname = parsed.pathname.toLowerCase();
|
|
452
|
+
|
|
453
|
+
if (/\.(mp3|m4a|m4b|m4p|aac|flac|wav|ogg|opus|oga|wma)$/i.test(pathname)) return true;
|
|
454
|
+
if (host === 'music.youtube.com') return true;
|
|
455
|
+
if (
|
|
456
|
+
host === 'bandcamp.com' ||
|
|
457
|
+
host.endsWith('.bandcamp.com') ||
|
|
458
|
+
host === 'soundcloud.com' ||
|
|
459
|
+
host.endsWith('.soundcloud.com') ||
|
|
460
|
+
host === 'mixcloud.com' ||
|
|
461
|
+
host.endsWith('.mixcloud.com') ||
|
|
462
|
+
host === 'audiomack.com' ||
|
|
463
|
+
host.endsWith('.audiomack.com') ||
|
|
464
|
+
host === 'audius.co' ||
|
|
465
|
+
host.endsWith('.audius.co')
|
|
466
|
+
) {
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
return host === 'vk.com' && /^\/audio/.test(pathname);
|
|
470
|
+
} catch (_) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function applyVideoModeForUrl(opts, url) {
|
|
476
|
+
if (!isVideoUrl(url)) return false;
|
|
477
|
+
opts.bestVideoMerge = true;
|
|
478
|
+
opts.extractAudio = false;
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function applyAudioModeForUrl(opts, url) {
|
|
483
|
+
if (!isAudioUrl(url)) return false;
|
|
484
|
+
opts.bestVideoMerge = false;
|
|
485
|
+
opts.extractAudio = true;
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
|
|
407
489
|
function isYouTubePlaylistUrl(url) {
|
|
408
490
|
try {
|
|
409
491
|
const parsed = new URL(url);
|
|
@@ -1059,7 +1141,14 @@ class EvaNodeTUI {
|
|
|
1059
1141
|
this.selectedQueueItem = null;
|
|
1060
1142
|
this.queueScrollOffset = 0;
|
|
1061
1143
|
this.queueViewport = null;
|
|
1144
|
+
this.libraryScrollOffset = 0;
|
|
1145
|
+
this.libraryViewport = null;
|
|
1146
|
+
this.selectedLibraryFile = null;
|
|
1147
|
+
this.lastLibraryClick = null;
|
|
1062
1148
|
this.statusBanner = '';
|
|
1149
|
+
this.cliMarqueeTimer = null;
|
|
1150
|
+
this.cliMarqueeText = '';
|
|
1151
|
+
this.cliMarqueeOffset = 0;
|
|
1063
1152
|
|
|
1064
1153
|
this.modal = null;
|
|
1065
1154
|
}
|
|
@@ -1082,10 +1171,51 @@ class EvaNodeTUI {
|
|
|
1082
1171
|
}
|
|
1083
1172
|
|
|
1084
1173
|
quit() {
|
|
1174
|
+
if (this.cliMarqueeTimer) {
|
|
1175
|
+
clearInterval(this.cliMarqueeTimer);
|
|
1176
|
+
this.cliMarqueeTimer = null;
|
|
1177
|
+
}
|
|
1085
1178
|
process.stdout.write(ANSI.mouseOff + ANSI.showCursor + ANSI.altScreenOff + ANSI.reset + '\n');
|
|
1086
1179
|
process.exit(0);
|
|
1087
1180
|
}
|
|
1088
1181
|
|
|
1182
|
+
_stopCliMarquee() {
|
|
1183
|
+
if (this.cliMarqueeTimer) {
|
|
1184
|
+
clearInterval(this.cliMarqueeTimer);
|
|
1185
|
+
this.cliMarqueeTimer = null;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
_renderCliPreview(scr, x, y, width, text) {
|
|
1190
|
+
if (text !== this.cliMarqueeText) {
|
|
1191
|
+
this.cliMarqueeText = text;
|
|
1192
|
+
this.cliMarqueeOffset = 0;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (text.length <= width) {
|
|
1196
|
+
this._stopCliMarquee();
|
|
1197
|
+
scr.putStr(y, x, text, ANSI.fgYellow);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const gap = ' ';
|
|
1202
|
+
const stream = text + gap + text;
|
|
1203
|
+
const loopLength = text.length + gap.length;
|
|
1204
|
+
const start = this.cliMarqueeOffset % loopLength;
|
|
1205
|
+
scr.putStr(y, x, stream.slice(start, start + width).padEnd(width), ANSI.fgYellow);
|
|
1206
|
+
|
|
1207
|
+
if (!this.cliMarqueeTimer && !this.modal) {
|
|
1208
|
+
this.cliMarqueeTimer = setInterval(() => {
|
|
1209
|
+
if (this.activeTab !== 1 || this.modal) {
|
|
1210
|
+
this._stopCliMarquee();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
this.cliMarqueeOffset = (this.cliMarqueeOffset + 1) % loopLength;
|
|
1214
|
+
this.render();
|
|
1215
|
+
}, 175);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1089
1219
|
registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
|
|
1090
1220
|
this.focusOrder.push(id);
|
|
1091
1221
|
const isFocused = this.focusId === id;
|
|
@@ -1103,7 +1233,7 @@ class EvaNodeTUI {
|
|
|
1103
1233
|
? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
|
|
1104
1234
|
: ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
|
|
1105
1235
|
} else if (isCta) {
|
|
1106
|
-
style =
|
|
1236
|
+
style = ['url_input', 'edit_proxy', 'cycle_browser'].includes(id)
|
|
1107
1237
|
? ANSI.fgWhite + ANSI.bold
|
|
1108
1238
|
: id === 'btn_add_queue'
|
|
1109
1239
|
? ANSI.fgGreen + ANSI.bold
|
|
@@ -1129,7 +1259,7 @@ class EvaNodeTUI {
|
|
|
1129
1259
|
}
|
|
1130
1260
|
|
|
1131
1261
|
const textX = x + prefix.length;
|
|
1132
|
-
const editMarker = '[
|
|
1262
|
+
const editMarker = '[✎ ]';
|
|
1133
1263
|
const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
|
|
1134
1264
|
if (!isFocused && editMarkerIndex >= 0) {
|
|
1135
1265
|
const folderLabel = 'Папка:';
|
|
@@ -1139,6 +1269,10 @@ class EvaNodeTUI {
|
|
|
1139
1269
|
scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
|
|
1140
1270
|
const afterFolderIndex = folderLabelIndex + folderLabel.length;
|
|
1141
1271
|
scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
|
|
1272
|
+
} else if (id === 'edit_outdir' && visibleLabel.startsWith('🗁')) {
|
|
1273
|
+
const iconLength = '🗁'.length;
|
|
1274
|
+
scr.putStr(y, textX, visibleLabel.slice(0, iconLength), style + ANSI.bold);
|
|
1275
|
+
scr.putStr(y, textX + iconLength, visibleLabel.slice(iconLength, editMarkerIndex), style);
|
|
1142
1276
|
} else {
|
|
1143
1277
|
scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
|
|
1144
1278
|
}
|
|
@@ -1181,6 +1315,8 @@ class EvaNodeTUI {
|
|
|
1181
1315
|
// -------------------------------------------------------------------------
|
|
1182
1316
|
|
|
1183
1317
|
render() {
|
|
1318
|
+
if (this.activeTab !== 1 || this.modal) this._stopCliMarquee();
|
|
1319
|
+
|
|
1184
1320
|
if (this.selectedQueueItem) {
|
|
1185
1321
|
const selectedIndex = this.manager.items.indexOf(this.selectedQueueItem);
|
|
1186
1322
|
if (selectedIndex >= 0) this.selectedQueueIdx = selectedIndex;
|
|
@@ -1194,6 +1330,7 @@ class EvaNodeTUI {
|
|
|
1194
1330
|
this.focusOrder = [];
|
|
1195
1331
|
this.hitboxes = [];
|
|
1196
1332
|
this.queueViewport = null;
|
|
1333
|
+
this.libraryViewport = null;
|
|
1197
1334
|
|
|
1198
1335
|
if (rows < 22 || cols < 76) {
|
|
1199
1336
|
scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
|
|
@@ -1214,15 +1351,22 @@ class EvaNodeTUI {
|
|
|
1214
1351
|
}
|
|
1215
1352
|
|
|
1216
1353
|
_renderHeader(scr, cols) {
|
|
1217
|
-
|
|
1354
|
+
const logoRight = 2 + Math.max(...STRETCHED_ASCII_LOGO.map((line) => line.length));
|
|
1355
|
+
STRETCHED_ASCII_LOGO.forEach((line, i) => {
|
|
1218
1356
|
scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
|
|
1219
1357
|
});
|
|
1220
1358
|
scr.putStr(7, 2, 'EVA MEDIA DOWNLOADER', ANSI.fgCyan);
|
|
1221
|
-
|
|
1359
|
+
const versionX = cols - APP_VERSION.length - 3;
|
|
1360
|
+
if (versionX > logoRight + 1) {
|
|
1361
|
+
scr.putStr(2, versionX, APP_VERSION, ANSI.fgWhite);
|
|
1362
|
+
}
|
|
1222
1363
|
if (cols >= 90) {
|
|
1223
1364
|
const tagline = 'Music. Videos. Your collection.';
|
|
1224
|
-
|
|
1225
|
-
|
|
1365
|
+
const taglineX = cols - tagline.length - 3;
|
|
1366
|
+
if (taglineX > logoRight + 1) {
|
|
1367
|
+
scr.putStr(5, taglineX, tagline, ANSI.fgWhite);
|
|
1368
|
+
scr.putStr(6, taglineX, '─'.repeat(tagline.length), ANSI.fgCyan);
|
|
1369
|
+
}
|
|
1226
1370
|
}
|
|
1227
1371
|
}
|
|
1228
1372
|
|
|
@@ -1286,17 +1430,10 @@ class EvaNodeTUI {
|
|
|
1286
1430
|
const activeItems = items.filter((item) => item.status === 'Downloading');
|
|
1287
1431
|
const doneCnt = items.filter((i) => i.status === 'Done').length;
|
|
1288
1432
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
const sum = activeItems.reduce((acc, i) => acc + (i.progress || 0), 0);
|
|
1292
|
-
pct = Math.round(sum / activeItems.length);
|
|
1293
|
-
} else if (items.length > 0 && doneCnt === items.length) {
|
|
1294
|
-
pct = 100;
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
|
|
1433
|
+
const eta = formatEta(activeItems[0] && activeItems[0].etaStr);
|
|
1434
|
+
const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${eta}`;
|
|
1298
1435
|
const queueStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
|
|
1299
|
-
const queueStartX = cols - queueStatus.length;
|
|
1436
|
+
const queueStartX = cols - queueStatus.length - 1;
|
|
1300
1437
|
scr.putStr(rows - 1, queueStartX, queueStatus, queueStyle);
|
|
1301
1438
|
|
|
1302
1439
|
const statusX = queueStartX + queueStatus.length + 2;
|
|
@@ -1358,96 +1495,95 @@ class EvaNodeTUI {
|
|
|
1358
1495
|
y += 7;
|
|
1359
1496
|
|
|
1360
1497
|
// --- Box 2: Download Options & yt-dlp Flags ---
|
|
1361
|
-
const box2H =
|
|
1498
|
+
const box2H = 12;
|
|
1362
1499
|
scr.clipRight = previousClipRight;
|
|
1363
1500
|
scr.drawBox(y, x, box2H, w, 'Download Options Flags');
|
|
1364
1501
|
scr.clipRight = x + w - 2;
|
|
1365
1502
|
const c1 = x + 2;
|
|
1366
1503
|
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1504
|
+
const renderAudioFormat = (row) => {
|
|
1505
|
+
scr.putStr(row, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
|
|
1506
|
+
let fx = c1 + 8;
|
|
1507
|
+
AUDIO_FORMATS.forEach((fmt) => {
|
|
1508
|
+
fx =
|
|
1509
|
+
this.registerWidget(
|
|
1510
|
+
scr,
|
|
1511
|
+
`fmt_${fmt}`,
|
|
1512
|
+
row,
|
|
1513
|
+
fx,
|
|
1514
|
+
fmt,
|
|
1515
|
+
() => {
|
|
1516
|
+
this.opts.audioFormat = fmt;
|
|
1517
|
+
this.opts.extractAudio = true;
|
|
1518
|
+
this.opts.bestVideoMerge = false;
|
|
1519
|
+
this.statusBanner = `Формат: --audio-format ${fmt}`;
|
|
1520
|
+
this.render();
|
|
1521
|
+
},
|
|
1522
|
+
{ radio: this.opts.audioFormat === fmt }
|
|
1523
|
+
) + 2;
|
|
1524
|
+
});
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
const renderAudioQuality = (row) => {
|
|
1528
|
+
scr.putStr(row, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
|
|
1529
|
+
let qx = c1 + 9;
|
|
1530
|
+
AUDIO_QUALITIES.forEach((q) => {
|
|
1531
|
+
qx =
|
|
1532
|
+
this.registerWidget(
|
|
1533
|
+
scr,
|
|
1534
|
+
`qual_${q.val}`,
|
|
1535
|
+
row,
|
|
1536
|
+
qx,
|
|
1537
|
+
q.label,
|
|
1538
|
+
() => {
|
|
1539
|
+
this.opts.audioQuality = q.val;
|
|
1540
|
+
this.statusBanner = `Качество: --audio-quality ${q.val}`;
|
|
1541
|
+
this.render();
|
|
1542
|
+
},
|
|
1543
|
+
{ radio: this.opts.audioQuality === q.val }
|
|
1544
|
+
) + 2;
|
|
1545
|
+
});
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
{
|
|
1549
|
+
const videoX = c1 + Math.floor((w - 4) / 2);
|
|
1550
|
+
const videoRight = x + w - 2;
|
|
1551
|
+
|
|
1552
|
+
scr.putStr(y + 1, c1, 'АУДИО:', ANSI.fgCyan + ANSI.bold);
|
|
1371
1553
|
this.registerWidget(
|
|
1372
1554
|
scr,
|
|
1373
1555
|
'flag_x',
|
|
1374
1556
|
y + 1,
|
|
1375
|
-
|
|
1557
|
+
c1 + 7,
|
|
1376
1558
|
'Только аудио',
|
|
1377
1559
|
() => this._toggle('extractAudio'),
|
|
1378
|
-
{ checked: this.opts.extractAudio }
|
|
1379
|
-
)
|
|
1380
|
-
|
|
1560
|
+
{ checked: this.opts.extractAudio, maxWidth: videoX - c1 - 8 }
|
|
1561
|
+
);
|
|
1562
|
+
this.registerWidget(
|
|
1563
|
+
scr,
|
|
1564
|
+
'flag_thumb',
|
|
1565
|
+
y + 2,
|
|
1566
|
+
c1 + 7,
|
|
1567
|
+
'Встроить обложку',
|
|
1568
|
+
() => this._toggle('embedThumbnail'),
|
|
1569
|
+
{ checked: this.opts.embedThumbnail, maxWidth: videoX - c1 - 8 }
|
|
1570
|
+
);
|
|
1381
1571
|
this.registerWidget(
|
|
1382
1572
|
scr,
|
|
1383
1573
|
'flag_meta',
|
|
1384
|
-
y +
|
|
1385
|
-
|
|
1574
|
+
y + 3,
|
|
1575
|
+
c1 + 7,
|
|
1386
1576
|
'Встроить метаданные',
|
|
1387
1577
|
() => this._toggle('embedMetadata'),
|
|
1388
|
-
{ checked: this.opts.embedMetadata }
|
|
1389
|
-
)
|
|
1390
|
-
this.registerWidget(
|
|
1391
|
-
scr,
|
|
1392
|
-
'flag_thumb',
|
|
1393
|
-
y + 1,
|
|
1394
|
-
ax,
|
|
1395
|
-
'Встроить обложку',
|
|
1396
|
-
() => this._toggle('embedThumbnail'),
|
|
1397
|
-
{ checked: this.opts.embedThumbnail, maxWidth: x + w - ax - 2 }
|
|
1398
|
-
);
|
|
1399
|
-
|
|
1400
|
-
// Row 2: Format (--audio-format)
|
|
1401
|
-
scr.putStr(y + 2, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
|
|
1402
|
-
let fx = c1 + 8;
|
|
1403
|
-
AUDIO_FORMATS.forEach((fmt) => {
|
|
1404
|
-
fx =
|
|
1405
|
-
this.registerWidget(
|
|
1406
|
-
scr,
|
|
1407
|
-
`fmt_${fmt}`,
|
|
1408
|
-
y + 2,
|
|
1409
|
-
fx,
|
|
1410
|
-
fmt,
|
|
1411
|
-
() => {
|
|
1412
|
-
this.opts.audioFormat = fmt;
|
|
1413
|
-
this.opts.extractAudio = true;
|
|
1414
|
-
this.opts.bestVideoMerge = false;
|
|
1415
|
-
this.statusBanner = `Формат: --audio-format ${fmt}`;
|
|
1416
|
-
this.render();
|
|
1417
|
-
},
|
|
1418
|
-
{ radio: this.opts.audioFormat === fmt }
|
|
1419
|
-
) + 2;
|
|
1420
|
-
});
|
|
1421
|
-
|
|
1422
|
-
// Row 3: Quality (--audio-quality)
|
|
1423
|
-
scr.putStr(y + 3, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
|
|
1424
|
-
let qx = c1 + 9;
|
|
1425
|
-
AUDIO_QUALITIES.forEach((q) => {
|
|
1426
|
-
qx =
|
|
1427
|
-
this.registerWidget(
|
|
1428
|
-
scr,
|
|
1429
|
-
`qual_${q.val}`,
|
|
1430
|
-
y + 3,
|
|
1431
|
-
qx,
|
|
1432
|
-
q.label,
|
|
1433
|
-
() => {
|
|
1434
|
-
this.opts.audioQuality = q.val;
|
|
1435
|
-
this.statusBanner = `Качество: --audio-quality ${q.val}`;
|
|
1436
|
-
this.render();
|
|
1437
|
-
},
|
|
1438
|
-
{ radio: this.opts.audioQuality === q.val }
|
|
1439
|
-
) + 2;
|
|
1440
|
-
});
|
|
1578
|
+
{ checked: this.opts.embedMetadata, maxWidth: videoX - c1 - 8 }
|
|
1579
|
+
);
|
|
1441
1580
|
|
|
1442
|
-
|
|
1443
|
-
scr.putStr(y + 4, c1, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
|
|
1444
|
-
let vx = c1 + 7;
|
|
1445
|
-
vx =
|
|
1581
|
+
scr.putStr(y + 1, videoX, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
|
|
1446
1582
|
this.registerWidget(
|
|
1447
1583
|
scr,
|
|
1448
1584
|
'flag_bvbab',
|
|
1449
|
-
y +
|
|
1450
|
-
|
|
1585
|
+
y + 1,
|
|
1586
|
+
videoX + 7,
|
|
1451
1587
|
'Видео+Аудио',
|
|
1452
1588
|
() => {
|
|
1453
1589
|
this.opts.bestVideoMerge = !this.opts.bestVideoMerge;
|
|
@@ -1455,123 +1591,116 @@ class EvaNodeTUI {
|
|
|
1455
1591
|
this.statusBanner = this.opts.bestVideoMerge ? 'Режим видео -f "bv*+ba/b"' : 'Режим аудио -x';
|
|
1456
1592
|
this.render();
|
|
1457
1593
|
},
|
|
1458
|
-
{ checked: this.opts.bestVideoMerge }
|
|
1459
|
-
)
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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 });
|
|
1594
|
+
{ checked: this.opts.bestVideoMerge, maxWidth: videoRight - videoX - 6 }
|
|
1595
|
+
);
|
|
1596
|
+
this.registerWidget(
|
|
1597
|
+
scr,
|
|
1598
|
+
'flag_wthumb',
|
|
1599
|
+
y + 2,
|
|
1600
|
+
videoX + 7,
|
|
1601
|
+
'Скачать обложку',
|
|
1602
|
+
() => this._toggle('writeThumbnail'),
|
|
1603
|
+
{ checked: this.opts.writeThumbnail, maxWidth: videoRight - videoX - 6 }
|
|
1604
|
+
);
|
|
1605
|
+
this.registerWidget(
|
|
1606
|
+
scr,
|
|
1607
|
+
'flag_wsubs',
|
|
1608
|
+
y + 3,
|
|
1609
|
+
videoX + 7,
|
|
1610
|
+
'Файл субтитров',
|
|
1611
|
+
() => this._toggle('writeSubs'),
|
|
1612
|
+
{ checked: this.opts.writeSubs, maxWidth: videoRight - videoX - 6 }
|
|
1613
|
+
);
|
|
1614
|
+
this.registerWidget(
|
|
1615
|
+
scr,
|
|
1616
|
+
'flag_esubs',
|
|
1617
|
+
y + 4,
|
|
1618
|
+
videoX + 7,
|
|
1619
|
+
'Встроить субтитры',
|
|
1620
|
+
() => this._toggle('embedSubs'),
|
|
1621
|
+
{ checked: this.opts.embedSubs, maxWidth: videoRight - videoX - 6 }
|
|
1622
|
+
);
|
|
1496
1623
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
}
|
|
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 });
|
|
1624
|
+
renderAudioQuality(y + 5);
|
|
1625
|
+
renderAudioFormat(y + 6);
|
|
1626
|
+
}
|
|
1516
1627
|
|
|
1517
|
-
// Row
|
|
1518
|
-
scr.putStr(y +
|
|
1628
|
+
// Row 8: Playlists (--playlist-items, --playlist-reverse & --download-archive)
|
|
1629
|
+
scr.putStr(y + 8, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
|
|
1630
|
+
const rowRight = x + w - 2;
|
|
1519
1631
|
let px =
|
|
1520
1632
|
this.registerWidget(
|
|
1521
1633
|
scr,
|
|
1522
1634
|
'flag_plitems',
|
|
1523
|
-
y +
|
|
1524
|
-
c1 +
|
|
1525
|
-
|
|
1635
|
+
y + 8,
|
|
1636
|
+
c1 + 10,
|
|
1637
|
+
`Скачать № ${this.opts.playlistItems}`,
|
|
1526
1638
|
() => this._toggle('usePlaylistItems'),
|
|
1527
|
-
{ checked: this.opts.usePlaylistItems }
|
|
1639
|
+
{ checked: this.opts.usePlaylistItems, maxWidth: 18 }
|
|
1528
1640
|
) + 1;
|
|
1529
1641
|
px =
|
|
1530
|
-
this.registerWidget(scr, 'edit_plitems', y +
|
|
1642
|
+
this.registerWidget(scr, 'edit_plitems', y + 8, px, '[✎ ]', () =>
|
|
1531
1643
|
this.openPrompt('Номера треков (--playlist-items, напр. 1-5)', this.opts.playlistItems, (v) => {
|
|
1532
1644
|
this.opts.playlistItems = v || '1-5';
|
|
1533
1645
|
})
|
|
1534
|
-
) +
|
|
1646
|
+
) + 2;
|
|
1535
1647
|
|
|
1536
|
-
|
|
1648
|
+
px =
|
|
1649
|
+
this.registerWidget(
|
|
1650
|
+
scr,
|
|
1651
|
+
'flag_plrev',
|
|
1652
|
+
y + 8,
|
|
1653
|
+
px,
|
|
1654
|
+
'С конца',
|
|
1655
|
+
() => this._toggle('playlistReverse'),
|
|
1656
|
+
{ checked: this.opts.playlistReverse, maxWidth: Math.max(5, rowRight - px - 1) }
|
|
1657
|
+
) + 2;
|
|
1658
|
+
|
|
1659
|
+
const archiveEditWidth = '[✎ ]'.length;
|
|
1660
|
+
const archiveFlagWidth = Math.max(5, rowRight - px - archiveEditWidth);
|
|
1661
|
+
const archiveEnd = this.registerWidget(
|
|
1537
1662
|
scr,
|
|
1538
|
-
'
|
|
1539
|
-
y +
|
|
1663
|
+
'flag_archive',
|
|
1664
|
+
y + 8,
|
|
1540
1665
|
px,
|
|
1541
|
-
|
|
1542
|
-
() => this._toggle('
|
|
1543
|
-
{ checked: this.opts.
|
|
1666
|
+
`История ${this.opts.archiveFile}`,
|
|
1667
|
+
() => this._toggle('useDownloadArchive'),
|
|
1668
|
+
{ checked: this.opts.useDownloadArchive, maxWidth: archiveFlagWidth }
|
|
1544
1669
|
);
|
|
1670
|
+
if (archiveEnd + 1 <= rowRight) {
|
|
1671
|
+
this.registerWidget(scr, 'edit_archive', y + 8, archiveEnd + 1, '[✎ ]', () => {
|
|
1672
|
+
return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
|
|
1673
|
+
this.opts.archiveFile = v || 'archive.txt';
|
|
1674
|
+
});
|
|
1675
|
+
}, { maxWidth: Math.min(6, rowRight - archiveEnd) });
|
|
1676
|
+
}
|
|
1545
1677
|
|
|
1546
|
-
//
|
|
1678
|
+
// Keep the output controls on the last inner row of the options box.
|
|
1679
|
+
const outputControlsRow = y + box2H - 2;
|
|
1547
1680
|
const maxDirLen = Math.max(16, w - 48);
|
|
1548
1681
|
const shortDir =
|
|
1549
1682
|
this.opts.outputDir.length > maxDirLen
|
|
1550
1683
|
? '…' + this.opts.outputDir.slice(-(maxDirLen - 1))
|
|
1551
1684
|
: this.opts.outputDir;
|
|
1552
1685
|
let dx =
|
|
1553
|
-
this.registerWidget(scr, 'edit_outdir',
|
|
1686
|
+
this.registerWidget(scr, 'edit_outdir', outputControlsRow, c1, `📁: ${shortDir} [✎ ]`, () =>
|
|
1554
1687
|
this.openPrompt('Папка сохранения', this.opts.outputDir, (v) => {
|
|
1555
1688
|
this.opts.outputDir = v || path.join(os.homedir(), 'Downloads');
|
|
1556
1689
|
})
|
|
1557
1690
|
) + 3;
|
|
1558
1691
|
|
|
1559
|
-
const ctaLabel = 'Скачать
|
|
1692
|
+
const ctaLabel = '[A] Скачать';
|
|
1560
1693
|
const ctaX = Math.max(dx, x + w - ctaLabel.length - 3);
|
|
1561
|
-
this.registerWidget(scr, 'btn_add_queue',
|
|
1694
|
+
this.registerWidget(scr, 'btn_add_queue', outputControlsRow, ctaX, ctaLabel, () => this._actionAddToQueue(), {
|
|
1562
1695
|
isCta: true,
|
|
1563
1696
|
});
|
|
1564
1697
|
|
|
1565
1698
|
y += box2H;
|
|
1566
1699
|
scr.clipRight = previousClipRight;
|
|
1567
1700
|
|
|
1568
|
-
//
|
|
1701
|
+
// Scroll long command previews horizontally so they remain readable in compact windows.
|
|
1569
1702
|
const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
|
|
1570
|
-
|
|
1571
|
-
const availCliRows = Math.max(1, rows - 2 - y);
|
|
1572
|
-
wrappedCli.slice(0, availCliRows).forEach((line, idx) => {
|
|
1573
|
-
scr.putStr(y + idx, x + 1, line, ANSI.fgYellow);
|
|
1574
|
-
});
|
|
1703
|
+
this._renderCliPreview(scr, x + 1, y, w - 2, fullCliText);
|
|
1575
1704
|
}
|
|
1576
1705
|
|
|
1577
1706
|
// -------------------------------------------------------------------------
|
|
@@ -1754,23 +1883,102 @@ class EvaNodeTUI {
|
|
|
1754
1883
|
const nameColW = Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5);
|
|
1755
1884
|
const formatX = numberColW + 1 + nameColW + 1;
|
|
1756
1885
|
const sizeX = formatX + formatColW + 1;
|
|
1886
|
+
const listTop = 11;
|
|
1887
|
+
const listRows = Math.max(1, h - 4);
|
|
1888
|
+
const maxScrollOffset = Math.max(0, files.length - listRows);
|
|
1889
|
+
this.libraryScrollOffset = Math.max(0, Math.min(this.libraryScrollOffset, maxScrollOffset));
|
|
1890
|
+
this.libraryViewport = { x, right: x + w, top: listTop, rows: listRows, count: files.length };
|
|
1891
|
+
scr.putStr(10, x, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
|
|
1757
1892
|
if (files.length === 0) {
|
|
1758
|
-
scr.putStr(
|
|
1893
|
+
scr.putStr(listTop, x + 3, `No media files found in ${outDir}.`.slice(0, w - 6), ANSI.fgWhite);
|
|
1759
1894
|
} else {
|
|
1760
|
-
scr.putStr(
|
|
1761
|
-
scr.putStr(
|
|
1762
|
-
scr.putStr(
|
|
1763
|
-
scr.putStr(
|
|
1764
|
-
|
|
1765
|
-
|
|
1895
|
+
scr.putStr(9, x + 3, '#', ANSI.fgCyan + ANSI.bold);
|
|
1896
|
+
scr.putStr(9, x + 3 + numberColW + 1, 'Title', ANSI.fgCyan + ANSI.bold);
|
|
1897
|
+
scr.putStr(9, x + 3 + formatX, 'Format', ANSI.fgCyan + ANSI.bold);
|
|
1898
|
+
scr.putStr(9, x + 3 + sizeX, 'Size', ANSI.fgCyan + ANSI.bold);
|
|
1899
|
+
files.slice(this.libraryScrollOffset, this.libraryScrollOffset + listRows).forEach((f, rowOffset) => {
|
|
1900
|
+
const fileIndex = this.libraryScrollOffset + rowOffset;
|
|
1766
1901
|
const mb = formatBytesToMB(f.stat.size);
|
|
1767
|
-
const row =
|
|
1902
|
+
const row = listTop + rowOffset;
|
|
1768
1903
|
const filename = fitTextToCellWidth(f.name, nameColW);
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
scr.
|
|
1772
|
-
scr.
|
|
1904
|
+
const isSelected = f.full === this.selectedLibraryFile;
|
|
1905
|
+
const rowStyle = isSelected ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold : ANSI.fgWhite;
|
|
1906
|
+
if (isSelected) scr.putStr(row, x + 2, ' '.repeat(w - 4), rowStyle);
|
|
1907
|
+
scr.putStr(row, x + 3, String(fileIndex + 1).padEnd(numberColW), rowStyle);
|
|
1908
|
+
scr.putStrWithin(row, x + 3 + numberColW + 1, filename, nameColW, rowStyle);
|
|
1909
|
+
scr.putStrWithin(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), formatColW, rowStyle);
|
|
1910
|
+
scr.putStrWithin(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), sizeColW, rowStyle);
|
|
1911
|
+
this.hitboxes.push({
|
|
1912
|
+
id: `library_file_${fileIndex}`,
|
|
1913
|
+
y: row,
|
|
1914
|
+
x1: x + 2,
|
|
1915
|
+
x2: x + w - 3,
|
|
1916
|
+
action: () => this._selectLibraryFile(f),
|
|
1917
|
+
});
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
if (files.length > listRows) {
|
|
1921
|
+
const scrollbarX = x + w - 2;
|
|
1922
|
+
const thumbSize = Math.max(1, Math.floor((listRows * listRows) / files.length));
|
|
1923
|
+
const thumbTravel = listRows - thumbSize;
|
|
1924
|
+
const thumbTop = maxScrollOffset > 0
|
|
1925
|
+
? Math.round((this.libraryScrollOffset / maxScrollOffset) * thumbTravel)
|
|
1926
|
+
: 0;
|
|
1927
|
+
for (let rowOffset = 0; rowOffset < listRows; rowOffset++) {
|
|
1928
|
+
const isThumb = rowOffset >= thumbTop && rowOffset < thumbTop + thumbSize;
|
|
1929
|
+
const scrollbarY = listTop + rowOffset;
|
|
1930
|
+
scr.putStr(scrollbarY, scrollbarX, isThumb ? '█' : '│', isThumb ? ANSI.fgCyan : ANSI.fgGray);
|
|
1931
|
+
this.hitboxes.push({
|
|
1932
|
+
id: 'library_scroll',
|
|
1933
|
+
y: scrollbarY,
|
|
1934
|
+
x1: scrollbarX,
|
|
1935
|
+
x2: scrollbarX,
|
|
1936
|
+
action: () => {
|
|
1937
|
+
const nextThumbTop = Math.max(0, Math.min(thumbTravel, rowOffset - Math.floor(thumbSize / 2)));
|
|
1938
|
+
this.libraryScrollOffset = thumbTravel > 0
|
|
1939
|
+
? Math.round((nextThumbTop / thumbTravel) * maxScrollOffset)
|
|
1940
|
+
: 0;
|
|
1941
|
+
this.render();
|
|
1942
|
+
},
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
_selectLibraryFile(file) {
|
|
1950
|
+
const now = Date.now();
|
|
1951
|
+
const isDoubleClick =
|
|
1952
|
+
this.lastLibraryClick &&
|
|
1953
|
+
this.lastLibraryClick.path === file.full &&
|
|
1954
|
+
now - this.lastLibraryClick.time <= LIBRARY_DOUBLE_CLICK_MS;
|
|
1955
|
+
|
|
1956
|
+
this.selectedLibraryFile = file.full;
|
|
1957
|
+
this.lastLibraryClick = isDoubleClick ? null : { path: file.full, time: now };
|
|
1958
|
+
if (isDoubleClick) {
|
|
1959
|
+
this._openLibraryFile(file.full);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
this.render();
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
_openLibraryFile(filePath) {
|
|
1966
|
+
const opener = process.platform === 'win32'
|
|
1967
|
+
? 'explorer.exe'
|
|
1968
|
+
: process.platform === 'darwin'
|
|
1969
|
+
? 'open'
|
|
1970
|
+
: 'xdg-open';
|
|
1971
|
+
|
|
1972
|
+
try {
|
|
1973
|
+
const child = spawn(opener, [filePath], { detached: true, stdio: 'ignore' });
|
|
1974
|
+
child.once('error', (error) => {
|
|
1975
|
+
this.statusBanner = `Не удалось открыть файл: ${error.message}`;
|
|
1976
|
+
this.render();
|
|
1773
1977
|
});
|
|
1978
|
+
child.unref();
|
|
1979
|
+
} catch (error) {
|
|
1980
|
+
this.statusBanner = `Не удалось открыть файл: ${error.message}`;
|
|
1981
|
+
this.render();
|
|
1774
1982
|
}
|
|
1775
1983
|
}
|
|
1776
1984
|
|
|
@@ -1804,11 +2012,11 @@ class EvaNodeTUI {
|
|
|
1804
2012
|
() => this._toggle('useProxy'),
|
|
1805
2013
|
{ checked: this.opts.useProxy }
|
|
1806
2014
|
) + 2;
|
|
1807
|
-
this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () =>
|
|
1808
|
-
this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
|
|
2015
|
+
this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () => {
|
|
2016
|
+
return this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
|
|
1809
2017
|
this.opts.proxyUrl = v || 'http://127.0.0.1:8080';
|
|
1810
|
-
})
|
|
1811
|
-
);
|
|
2018
|
+
});
|
|
2019
|
+
}, { isCta: true });
|
|
1812
2020
|
|
|
1813
2021
|
let ckx =
|
|
1814
2022
|
this.registerWidget(
|
|
@@ -1827,9 +2035,30 @@ class EvaNodeTUI {
|
|
|
1827
2035
|
this.opts.useCookiesBrowser = nextB !== 'none';
|
|
1828
2036
|
this.statusBanner = `Cookies браузер: ${nextB}`;
|
|
1829
2037
|
this.render();
|
|
1830
|
-
});
|
|
2038
|
+
}, { isCta: true });
|
|
2039
|
+
|
|
2040
|
+
const namingHeading = 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ';
|
|
2041
|
+
scr.putStr(15, c1, namingHeading, ANSI.fgCyan + ANSI.bold);
|
|
2042
|
+
const namingTemplateX = c1 + namingHeading.length + 1;
|
|
2043
|
+
const namingRight = x + w - 2;
|
|
2044
|
+
const namingTemplateEnd = this.registerWidget(
|
|
2045
|
+
scr,
|
|
2046
|
+
'flag_otpl',
|
|
2047
|
+
15,
|
|
2048
|
+
namingTemplateX,
|
|
2049
|
+
`-o "${this.opts.outputTemplate}"`,
|
|
2050
|
+
() => this._toggle('usePlaylistTemplate'),
|
|
2051
|
+
{
|
|
2052
|
+
checked: this.opts.usePlaylistTemplate,
|
|
2053
|
+
maxWidth: Math.max(5, namingRight - namingTemplateX - '[✎ ]'.length),
|
|
2054
|
+
}
|
|
2055
|
+
);
|
|
2056
|
+
this.registerWidget(scr, 'edit_otpl', 15, namingTemplateEnd + 1, '[✎ ]', () => {
|
|
2057
|
+
return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
|
|
2058
|
+
this.opts.outputTemplate = v || '%(title)s.%(ext)s';
|
|
2059
|
+
});
|
|
2060
|
+
}, { maxWidth: 6 });
|
|
1831
2061
|
|
|
1832
|
-
scr.putStr(15, c1, 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ (-o):', ANSI.fgCyan + ANSI.bold);
|
|
1833
2062
|
NAMING_PRESETS.forEach((p, idx) => {
|
|
1834
2063
|
this.registerWidget(
|
|
1835
2064
|
scr,
|
|
@@ -1907,8 +2136,8 @@ class EvaNodeTUI {
|
|
|
1907
2136
|
'Интегрированные флаги yt-dlp:',
|
|
1908
2137
|
' • Аудио: -x, --audio-format (mp3/flac/m4a/wav), --audio-quality 0, --embed-metadata, --embed-thumbnail',
|
|
1909
2138
|
' • Видео: -f "bv*+ba/b", --write-subs, --embed-subs, --write-thumbnail, --merge-output-format mp4',
|
|
1910
|
-
' •
|
|
1911
|
-
' • Плейлисты: --playlist-items 1-5, --playlist-reverse',
|
|
2139
|
+
' • Файлы (вкладка Settings): -o "%(playlist_index)s. %(title)s.%(ext)s"',
|
|
2140
|
+
' • Плейлисты: --playlist-items 1-5, --playlist-reverse, --download-archive archive.txt',
|
|
1912
2141
|
' • Сеть (вкладка Settings): -4, --proxy http://127.0.0.1:8080, --cookies-from-browser chrome',
|
|
1913
2142
|
` `,
|
|
1914
2143
|
` `,
|
|
@@ -1941,11 +2170,17 @@ class EvaNodeTUI {
|
|
|
1941
2170
|
_setUrl(val) {
|
|
1942
2171
|
this.opts.url = val;
|
|
1943
2172
|
if (val) {
|
|
2173
|
+
const videoMode = applyVideoModeForUrl(this.opts, val);
|
|
2174
|
+
const audioMode = !videoMode && applyAudioModeForUrl(this.opts, val);
|
|
1944
2175
|
const detected = detectSource(val, this.opts.sourceId);
|
|
1945
2176
|
if (this.opts.sourceId !== 1) {
|
|
1946
2177
|
this.opts.sourceId = detected.id;
|
|
1947
2178
|
}
|
|
1948
|
-
this.statusBanner =
|
|
2179
|
+
this.statusBanner = videoMode
|
|
2180
|
+
? `Видео+Аудио (${detected.name})`
|
|
2181
|
+
: audioMode
|
|
2182
|
+
? `Только аудио (${detected.name})`
|
|
2183
|
+
: `URL (${detected.name})`;
|
|
1949
2184
|
}
|
|
1950
2185
|
this.render();
|
|
1951
2186
|
}
|
|
@@ -2030,14 +2265,20 @@ class EvaNodeTUI {
|
|
|
2030
2265
|
const mx = parseInt(mouseMatch[2], 10) - 1;
|
|
2031
2266
|
const my = parseInt(mouseMatch[3], 10) - 1;
|
|
2032
2267
|
const isPress = mouseMatch[4] === 'M';
|
|
2033
|
-
if (this.activeTab === 2 && (btn === 64 || btn === 65)) {
|
|
2034
|
-
const
|
|
2268
|
+
if ((this.activeTab === 2 || this.activeTab === 3) && (btn === 64 || btn === 65)) {
|
|
2269
|
+
const isQueue = this.activeTab === 2;
|
|
2270
|
+
const viewport = isQueue ? this.queueViewport : this.libraryViewport;
|
|
2035
2271
|
if (viewport && mx >= viewport.x && mx < viewport.right &&
|
|
2036
2272
|
my >= viewport.top && my < viewport.top + viewport.rows) {
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2273
|
+
if (isQueue) {
|
|
2274
|
+
const maxOffset = Math.max(0, this.manager.items.length - viewport.rows);
|
|
2275
|
+
this.queueScrollOffset = Math.max(0, Math.min(maxOffset, this.queueScrollOffset + (btn === 64 ? -1 : 1)));
|
|
2276
|
+
this.selectedQueueIdx = this.queueScrollOffset;
|
|
2277
|
+
this.selectedQueueItem = this.manager.items[this.selectedQueueIdx] || null;
|
|
2278
|
+
} else {
|
|
2279
|
+
const maxOffset = Math.max(0, viewport.count - viewport.rows);
|
|
2280
|
+
this.libraryScrollOffset = Math.max(0, Math.min(maxOffset, this.libraryScrollOffset + (btn === 64 ? -1 : 1)));
|
|
2281
|
+
}
|
|
2041
2282
|
this.render();
|
|
2042
2283
|
}
|
|
2043
2284
|
return;
|
|
@@ -2047,7 +2288,7 @@ class EvaNodeTUI {
|
|
|
2047
2288
|
mx >= h.x1 && mx <= h.x2 && (h.y === my || (h.nav && Math.abs(h.y - my) === 1))
|
|
2048
2289
|
);
|
|
2049
2290
|
if (hit) {
|
|
2050
|
-
if (hit.id !== 'q_scroll') this.focusId = hit.id;
|
|
2291
|
+
if (hit.id !== 'q_scroll' && hit.id !== 'library_scroll') this.focusId = hit.id;
|
|
2051
2292
|
hit.action();
|
|
2052
2293
|
}
|
|
2053
2294
|
}
|
|
@@ -2137,7 +2378,10 @@ function main() {
|
|
|
2137
2378
|
const urlArg = argv.find((a) => !a.startsWith('-')) || '';
|
|
2138
2379
|
|
|
2139
2380
|
const opts = loadSavedOptions();
|
|
2140
|
-
if (urlArg)
|
|
2381
|
+
if (urlArg) {
|
|
2382
|
+
opts.url = urlArg;
|
|
2383
|
+
if (!applyVideoModeForUrl(opts, urlArg)) applyAudioModeForUrl(opts, urlArg);
|
|
2384
|
+
}
|
|
2141
2385
|
|
|
2142
2386
|
if (isPrintCmd) {
|
|
2143
2387
|
console.log(buildCommandPreview(opts));
|
|
@@ -2192,11 +2436,16 @@ module.exports = {
|
|
|
2192
2436
|
buildPlaylistDiscoveryArgs,
|
|
2193
2437
|
buildCommandPreview,
|
|
2194
2438
|
detectSource,
|
|
2439
|
+
isVideoUrl,
|
|
2440
|
+
isAudioUrl,
|
|
2441
|
+
applyVideoModeForUrl,
|
|
2442
|
+
applyAudioModeForUrl,
|
|
2195
2443
|
isYouTubePlaylistUrl,
|
|
2196
2444
|
playlistEntryUrl,
|
|
2197
2445
|
ScreenBuffer,
|
|
2198
2446
|
DownloadManager,
|
|
2199
2447
|
formatBytesToMB,
|
|
2448
|
+
formatEta,
|
|
2200
2449
|
fitTextToCellWidth,
|
|
2201
2450
|
wrapTextByWords,
|
|
2202
2451
|
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eva-md",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.3",
|
|
4
|
+
"description": "Eva Media Downloader CLI",
|
|
5
5
|
"main": "eva.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"eva": "
|
|
7
|
+
"eva": "eva.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"eva.js",
|