eva-md 0.0.0-stage → 0.3.2
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 +40 -3
- package/eva.js +1748 -0
- package/package.json +23 -6
package/eva.js
ADDED
|
@@ -0,0 +1,1748 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* EVA.md v0.3.2 — EVA Media Downloader by .maxon
|
|
4
|
+
* Pure Node.js Interactive TUI (Zero external npm dependencies!)
|
|
5
|
+
* Works just like Gemini CLI (`node eva.js` or `./eva.js`), with full mouse & keyboard support.
|
|
6
|
+
*
|
|
7
|
+
* Supported Sources (1-9):
|
|
8
|
+
* 01. Авто 05. Mixcloud 09. Internet Archive
|
|
9
|
+
* 02. YouTube / Music 06. Audiomack
|
|
10
|
+
* 03. Bandcamp 07. Audius
|
|
11
|
+
* 04. SoundCloud 08. ВКонтакте (VK)
|
|
12
|
+
*
|
|
13
|
+
* Integrated yt-dlp Flags ("Buttons" / Interactive Controls):
|
|
14
|
+
* Audio (Музыка):
|
|
15
|
+
* -x Extract audio only
|
|
16
|
+
* --audio-format <fmt> mp3, flac, m4a, wav
|
|
17
|
+
* --audio-quality <q> 0 (best), 320K, 192K, 128K
|
|
18
|
+
* --embed-metadata Embed artist, title, album tags
|
|
19
|
+
* --embed-thumbnail Embed album art into audio file
|
|
20
|
+
* Video (Видео):
|
|
21
|
+
* -f "bv*+ba/b" Download best video + best audio & merge
|
|
22
|
+
* --write-subs Download subtitles file
|
|
23
|
+
* --embed-subs Embed subtitles into media container
|
|
24
|
+
* --write-thumbnail Save thumbnail as a separate image file
|
|
25
|
+
* File Naming & Archive (Именование файлов):
|
|
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
|
+
* Playlists (Плейлисты):
|
|
29
|
+
* --playlist-items <range> Download specific items (e.g. "1-5")
|
|
30
|
+
* --playlist-reverse Download playlist in reverse order
|
|
31
|
+
* Network & Bypass (Сеть и обход блокировок — во вкладке Settings):
|
|
32
|
+
* -4 Force IPv4 connection (fixes DNS issues)
|
|
33
|
+
* --proxy <url> Use HTTP/SOCKS proxy (e.g. http://127.0.0.1:8080)
|
|
34
|
+
* --cookies-from-browser <b> Extract cookies from browser (chrome, firefox, edge, brave, safari)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const os = require('os');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const { StringDecoder } = require('string_decoder');
|
|
43
|
+
const { spawn, spawnSync } = require('child_process');
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Constants & Configuration
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const APP_VERSION = 'v0.3.2 (Node.js)';
|
|
50
|
+
const CONFIG_PATH = path.join(os.homedir(), '.config', 'eva_md', 'config.json');
|
|
51
|
+
|
|
52
|
+
// Strictly 1-cell narrow glyphs (CP437/WGL4 safe) so Windows Terminal / PowerShell NEVER shifts borders!
|
|
53
|
+
const SOURCES = [
|
|
54
|
+
{ id: 1, code: '01', icon: '*', name: 'Авто', domain: '', hint: 'URL любого поддерживаемого сервиса' },
|
|
55
|
+
{ id: 2, code: '02', icon: '►', name: 'YouTube / Music', domain: 'youtube.com', hint: 'https://www.youtube.com/watch?v=... или https://music.youtube.com/watch?v=...' },
|
|
56
|
+
{ id: 3, code: '03', icon: '♦', name: 'Bandcamp', domain: 'bandcamp.com', hint: 'https://artist.bandcamp.com/album/...' },
|
|
57
|
+
{ id: 4, code: '04', icon: '≈', name: 'SoundCloud', domain: 'soundcloud.com', hint: 'https://soundcloud.com/artist/track' },
|
|
58
|
+
{ id: 5, code: '05', icon: 'ʍ', name: 'Mixcloud', domain: 'mixcloud.com', hint: 'https://www.mixcloud.com/artist/show/' },
|
|
59
|
+
{ id: 6, code: '06', icon: '~', name: 'Audiomack', domain: 'audiomack.com', hint: 'https://audiomack.com/artist/song/...' },
|
|
60
|
+
{ id: 7, code: '07', icon: 'Δ', name: 'Audius', domain: 'audius.co', hint: 'https://audius.co/artist/track' },
|
|
61
|
+
{ id: 8, code: '08', icon: 'V', name: 'ВКонтакте (VK)', domain: 'vk.com', hint: 'https://vk.com/audio... or https://vkvideo.ru/...' },
|
|
62
|
+
{ id: 9, code: '09', icon: '⌂', name: 'Internet Archive', domain: 'archive.org', hint: 'https://archive.org/details/...' },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const AUDIO_FORMATS = ['mp3', 'm4a', 'flac', 'wav'];
|
|
66
|
+
const AUDIO_QUALITIES = [
|
|
67
|
+
{ label: 'best', val: '0' },
|
|
68
|
+
{ label: '320k', val: '320K' },
|
|
69
|
+
{ label: '192k', val: '192K' },
|
|
70
|
+
{ label: '128k', val: '128K' },
|
|
71
|
+
];
|
|
72
|
+
const BROWSERS = ['none', 'chrome', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'safari'];
|
|
73
|
+
|
|
74
|
+
const NAMING_PRESETS = [
|
|
75
|
+
{ label: 'Плейлист: 01. Название файла.mp4(формат)', tpl: '%(playlist_index)s. %(title)s.%(ext)s' },
|
|
76
|
+
{ label: 'Стандарт: Название файла.mp4(формат)', tpl: '%(title)s.%(ext)s' },
|
|
77
|
+
{ label: 'Автор - Название: Автор - Название.mp4(формат)', tpl: '%(uploader)s - %(title)s.%(ext)s' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const ASCII_LOGO = [
|
|
81
|
+
'███████╗██╗ ██╗ █████╗ ⠀⠨⣔⢺⢿⡿⠟⠛⠋⠩⠥⠄⠀⠌⠉⠝⠻⠿⣿⣿⣷⣿⣿⣳⡀',
|
|
82
|
+
'██╔════╝██║ ██║██╔══██╗ ███╗ ███╗ ██████╗⠀⠀ ⢳⣭⢯⡆⠁⠠⠀⢀⣠⣤⠀⠀⠈⠀⠀⠈⡑⢬⢻⣿⣿⣿⣿⣧⡂⠌⠀ ',
|
|
83
|
+
'█████╗ ╚██╗ ██╔╝███████║ ██╔████╔██╗██╔══██╗ ⠈⠲⣽⣻⣽⢦⠀⠠⠝⣿⣷⡀⠀⠶⠂⠀⣸⣿⣟⠕⢼⣿⣿⣿⢿⢦',
|
|
84
|
+
'██╔══╝ ╚████╔╝ ██╔══██║ ██║╚██╔╝██║██║ ██║ ⢣⣼⣻⣿⣟⢇⠀⡜⠤⠻⢿⣄⣀⣀⠼⢟⡻⣃⠀⠘⣿⣿⠟',
|
|
85
|
+
'███████╗ ╚██╔╝ ██║ ██║ ██╗██║ ╚═╝ ██║██████╔╝ ⠐⢮⡿⣽⣿⣿⣞⡵⢢⣌⣦⣕⣢⣬⣧⣺⣿⣿⣿⣿⠝',
|
|
86
|
+
'╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ⠲⠕⠻⢿⣿⡻⣿⢯⢟⡿⢯⠟⠛⠻⠝⠋',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// ANSI Styling Helpers
|
|
90
|
+
const CSI = '\x1b[';
|
|
91
|
+
const ANSI = {
|
|
92
|
+
reset: `${CSI}0m`,
|
|
93
|
+
bold: `${CSI}1m`,
|
|
94
|
+
dim: `${CSI}2m`,
|
|
95
|
+
fgWhite: `${CSI}37m`,
|
|
96
|
+
fgCyan: `${CSI}38;2;8;128;128m`,
|
|
97
|
+
fgGreen: `${CSI}32m`,
|
|
98
|
+
fgYellow: `${CSI}33m`,
|
|
99
|
+
fgRed: `${CSI}31m`,
|
|
100
|
+
fgBlack: `${CSI}30m`,
|
|
101
|
+
fgGray: `${CSI}38;2;105;105;105m`,
|
|
102
|
+
fgOrange: `${CSI}38;2;222;91;9m`, // Текст цвета RGB(222, 91, 9)
|
|
103
|
+
bgOrange: `${CSI}48;2;222;91;9m`,
|
|
104
|
+
bgGray: `${CSI}48;2;105;105;105m`,
|
|
105
|
+
bgWhite: `${CSI}47m`,
|
|
106
|
+
bgCyan: `${CSI}46m`,
|
|
107
|
+
hideCursor: `${CSI}?25l`,
|
|
108
|
+
showCursor: `${CSI}?25h`,
|
|
109
|
+
altScreenOn: `${CSI}?1049h`,
|
|
110
|
+
altScreenOff: `${CSI}?1049l`,
|
|
111
|
+
mouseOn: `${CSI}?1000h${CSI}?1006h`,
|
|
112
|
+
mouseOff: `${CSI}?1000l${CSI}?1006l`,
|
|
113
|
+
clearScreen: `${CSI}2J${CSI}H`,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function whichBinary(binName) {
|
|
117
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
118
|
+
const res = spawnSync(cmd, [binName], { encoding: 'utf8' });
|
|
119
|
+
if (res.status === 0 && res.stdout) {
|
|
120
|
+
return res.stdout.trim().split(/\r?\n/)[0];
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const bundledPath = binName === 'yt-dlp'
|
|
124
|
+
? require('youtube-dl-exec').constants.YOUTUBE_DL_PATH
|
|
125
|
+
: binName === 'ffmpeg'
|
|
126
|
+
? require('ffmpeg-static')
|
|
127
|
+
: null;
|
|
128
|
+
if (typeof bundledPath === 'string' && fs.existsSync(bundledPath)) {
|
|
129
|
+
return bundledPath;
|
|
130
|
+
}
|
|
131
|
+
} catch (_) {}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function shellQuote(arg) {
|
|
136
|
+
if (/[\s*%"'\\]/.test(arg)) {
|
|
137
|
+
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
138
|
+
}
|
|
139
|
+
return arg;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatBytesToMB(bytes) {
|
|
143
|
+
if (!bytes || bytes <= 0) return '—';
|
|
144
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function wrapTextByWords(text, maxWidth) {
|
|
148
|
+
if (maxWidth <= 10) return [text.slice(0, Math.max(1, maxWidth))];
|
|
149
|
+
const words = String(text).split(' ');
|
|
150
|
+
const lines = [];
|
|
151
|
+
let cur = '';
|
|
152
|
+
|
|
153
|
+
for (const w of words) {
|
|
154
|
+
if (!cur) {
|
|
155
|
+
if (w.length <= maxWidth) {
|
|
156
|
+
cur = w;
|
|
157
|
+
} else {
|
|
158
|
+
let rem = w;
|
|
159
|
+
while (rem.length > maxWidth) {
|
|
160
|
+
lines.push(rem.slice(0, maxWidth));
|
|
161
|
+
rem = ' ' + rem.slice(maxWidth);
|
|
162
|
+
}
|
|
163
|
+
cur = rem;
|
|
164
|
+
}
|
|
165
|
+
} else if (cur.length + 1 + w.length <= maxWidth) {
|
|
166
|
+
cur += ' ' + w;
|
|
167
|
+
} else {
|
|
168
|
+
lines.push(cur);
|
|
169
|
+
const indented = ' ' + w;
|
|
170
|
+
if (indented.length <= maxWidth) {
|
|
171
|
+
cur = indented;
|
|
172
|
+
} else {
|
|
173
|
+
let rem = indented;
|
|
174
|
+
while (rem.length > maxWidth) {
|
|
175
|
+
lines.push(rem.slice(0, maxWidth));
|
|
176
|
+
rem = ' ' + rem.slice(maxWidth);
|
|
177
|
+
}
|
|
178
|
+
cur = rem;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (cur) lines.push(cur);
|
|
183
|
+
return lines;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Options & Command Builder
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
function createDefaultOptions() {
|
|
191
|
+
return {
|
|
192
|
+
sourceId: 1,
|
|
193
|
+
url: '',
|
|
194
|
+
outputDir: path.join(os.homedir(), 'Downloads'),
|
|
195
|
+
|
|
196
|
+
// Audio (Музыка)
|
|
197
|
+
extractAudio: true, // -x
|
|
198
|
+
audioFormat: 'mp3', // --audio-format mp3|flac|m4a|wav
|
|
199
|
+
audioQuality: '0', // --audio-quality 0|320K|128K
|
|
200
|
+
embedMetadata: true, // --embed-metadata
|
|
201
|
+
embedThumbnail: true, // --embed-thumbnail
|
|
202
|
+
|
|
203
|
+
// Video (Видео)
|
|
204
|
+
bestVideoMerge: false, // -f "bv*+ba/b"
|
|
205
|
+
writeSubs: false, // --write-subs
|
|
206
|
+
embedSubs: false, // --embed-subs
|
|
207
|
+
writeThumbnail: false, // --write-thumbnail
|
|
208
|
+
|
|
209
|
+
// Naming & Archive (Именование файлов)
|
|
210
|
+
usePlaylistTemplate: true, // -o "%(playlist_index)s. %(title)s.%(ext)s"
|
|
211
|
+
outputTemplate: '%(playlist_index)s. %(title)s.%(ext)s',
|
|
212
|
+
useDownloadArchive: false, // --download-archive archive.txt
|
|
213
|
+
archiveFile: 'archive.txt',
|
|
214
|
+
|
|
215
|
+
// Playlists (Плейлисты)
|
|
216
|
+
usePlaylistItems: false, // --playlist-items 1-5
|
|
217
|
+
playlistItems: '1-5',
|
|
218
|
+
playlistReverse: false, // --playlist-reverse
|
|
219
|
+
|
|
220
|
+
// Network & Bypass (Сеть и обход блокировок — во вкладке Settings)
|
|
221
|
+
forceIpv4: false, // -4
|
|
222
|
+
useProxy: false, // --proxy http://127.0.0.1:8080
|
|
223
|
+
proxyUrl: 'http://127.0.0.1:8080',
|
|
224
|
+
useCookiesBrowser: false, // --cookies-from-browser chrome
|
|
225
|
+
cookiesBrowser: 'chrome',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function loadSavedOptions() {
|
|
230
|
+
const opts = createDefaultOptions();
|
|
231
|
+
try {
|
|
232
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
233
|
+
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
234
|
+
Object.assign(opts, raw);
|
|
235
|
+
}
|
|
236
|
+
} catch (_) {}
|
|
237
|
+
return opts;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildYtdlpArgs(opts, targetUrl = null) {
|
|
241
|
+
// Force UTF-8 output from yt-dlp so Russian/Cyrillic track titles never become '?' on Windows!
|
|
242
|
+
const args = ['--newline', '--progress', '--encoding', 'utf-8'];
|
|
243
|
+
|
|
244
|
+
// Audio vs Video
|
|
245
|
+
if (opts.extractAudio && !opts.bestVideoMerge) {
|
|
246
|
+
args.push('-x');
|
|
247
|
+
args.push('--audio-format', opts.audioFormat);
|
|
248
|
+
args.push('--audio-quality', opts.audioQuality);
|
|
249
|
+
} else if (opts.bestVideoMerge) {
|
|
250
|
+
args.push('-f', 'bv*+ba/b');
|
|
251
|
+
args.push('--merge-output-format', 'mp4');
|
|
252
|
+
if (opts.extractAudio) {
|
|
253
|
+
args.push('-x');
|
|
254
|
+
args.push('--audio-format', opts.audioFormat);
|
|
255
|
+
args.push('--audio-quality', opts.audioQuality);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Metadata, Thumbnails & Subtitles
|
|
260
|
+
if (opts.embedMetadata) args.push('--embed-metadata');
|
|
261
|
+
if (opts.embedThumbnail) args.push('--embed-thumbnail');
|
|
262
|
+
if (opts.writeSubs) args.push('--write-subs');
|
|
263
|
+
if (opts.embedSubs) args.push('--embed-subs');
|
|
264
|
+
if (opts.writeThumbnail) args.push('--write-thumbnail');
|
|
265
|
+
|
|
266
|
+
// File Naming & Archive
|
|
267
|
+
const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
|
|
268
|
+
const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
|
|
269
|
+
args.push('-o', path.join(outDir, tpl));
|
|
270
|
+
|
|
271
|
+
if (opts.useDownloadArchive && opts.archiveFile.trim()) {
|
|
272
|
+
args.push('--download-archive', path.join(outDir, opts.archiveFile.trim()));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Playlists
|
|
276
|
+
if (opts.usePlaylistItems && opts.playlistItems.trim()) {
|
|
277
|
+
args.push('--playlist-items', opts.playlistItems.trim());
|
|
278
|
+
}
|
|
279
|
+
if (opts.playlistReverse) {
|
|
280
|
+
args.push('--playlist-reverse');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Network & Bypass
|
|
284
|
+
if (opts.forceIpv4) {
|
|
285
|
+
args.push('-4');
|
|
286
|
+
}
|
|
287
|
+
if (opts.useProxy && opts.proxyUrl.trim()) {
|
|
288
|
+
args.push('--proxy', opts.proxyUrl.trim());
|
|
289
|
+
}
|
|
290
|
+
if (opts.useCookiesBrowser && opts.cookiesBrowser && opts.cookiesBrowser !== 'none') {
|
|
291
|
+
args.push('--cookies-from-browser', opts.cookiesBrowser);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const ffmpegPath = whichBinary('ffmpeg');
|
|
295
|
+
if (ffmpegPath) {
|
|
296
|
+
args.push('--ffmpeg-location', path.dirname(ffmpegPath));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const urlToUse = (targetUrl !== null ? targetUrl : opts.url).trim();
|
|
300
|
+
if (urlToUse) {
|
|
301
|
+
args.push(urlToUse);
|
|
302
|
+
}
|
|
303
|
+
return args;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildCommandPreview(opts) {
|
|
307
|
+
const url = opts.url.trim() ? opts.url.trim() : '<URL>';
|
|
308
|
+
const args = buildYtdlpArgs(opts, url);
|
|
309
|
+
return 'yt-dlp ' + args.map(shellQuote).join(' ');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function detectSource(url, defaultId = 1) {
|
|
313
|
+
const low = (url || '').toLowerCase();
|
|
314
|
+
if (low.includes('music.youtube.com')) return { id: 2, name: 'YouTube Music' };
|
|
315
|
+
if (low.includes('youtube.com') || low.includes('youtu.be')) return { id: 2, name: 'YouTube' };
|
|
316
|
+
if (low.includes('bandcamp.com')) return { id: 3, name: 'Bandcamp' };
|
|
317
|
+
if (low.includes('soundcloud.com')) return { id: 4, name: 'SoundCloud' };
|
|
318
|
+
if (low.includes('mixcloud.com')) return { id: 5, name: 'Mixcloud' };
|
|
319
|
+
if (low.includes('audiomack.com')) return { id: 6, name: 'Audiomack' };
|
|
320
|
+
if (low.includes('audius.co')) return { id: 7, name: 'Audius' };
|
|
321
|
+
if (low.includes('vk.com') || low.includes('vkvideo.ru')) return { id: 8, name: 'ВКонтакте (VK)' };
|
|
322
|
+
if (low.includes('archive.org')) return { id: 9, name: 'Internet Archive' };
|
|
323
|
+
const found = SOURCES.find((s) => s.id === defaultId) || SOURCES[0];
|
|
324
|
+
return { id: found.id, name: found.name };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Download Manager (Subprocess Spawner & Real-Time Progress Parser)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
class DownloadManager {
|
|
332
|
+
constructor(simulateIfMissing = false, onUpdate = () => {}) {
|
|
333
|
+
this.items = [];
|
|
334
|
+
this.nextId = 1;
|
|
335
|
+
this.ytdlpPath = whichBinary('yt-dlp');
|
|
336
|
+
this.ffmpegPath = whichBinary('ffmpeg');
|
|
337
|
+
this.simulateIfMissing = simulateIfMissing;
|
|
338
|
+
this.onUpdate = onUpdate;
|
|
339
|
+
|
|
340
|
+
this.progressRe = /\[download\]\s+(\d+(?:\.\d+)?)%\s+of\s+~?(\S+)(?:\s+at\s+(\S+))?(?:\s+ETA\s+(\S+))?/;
|
|
341
|
+
this.destRe = /\[(?:download|ExtractAudio|Merger)\]\s+Destination:\s+(.+)/;
|
|
342
|
+
this.alreadyRe = /\[download\]\s+(.+?)\s+has already been downloaded/;
|
|
343
|
+
this.mergerRe = /\[Merger\]\s+Merging formats into\s+"(.+?)"/;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
addTask(opts, startImmediately = true) {
|
|
347
|
+
const url = opts.url.trim();
|
|
348
|
+
const src = detectSource(url, opts.sourceId);
|
|
349
|
+
const fmt = opts.bestVideoMerge && !opts.extractAudio ? 'mp4/mkv' : opts.audioFormat;
|
|
350
|
+
const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
|
|
351
|
+
const cmdArgs = buildYtdlpArgs(opts, url);
|
|
352
|
+
|
|
353
|
+
let shortTitle = url.replace(/^https?:\/\//, '');
|
|
354
|
+
const item = {
|
|
355
|
+
id: this.nextId++,
|
|
356
|
+
url,
|
|
357
|
+
title: `[${src.name}] ${shortTitle.slice(0, 44)}`,
|
|
358
|
+
sourceName: src.name,
|
|
359
|
+
fmt,
|
|
360
|
+
outDir,
|
|
361
|
+
destFile: '',
|
|
362
|
+
sizeStr: '—',
|
|
363
|
+
progress: 0,
|
|
364
|
+
speedStr: '',
|
|
365
|
+
etaStr: '',
|
|
366
|
+
status: 'Queued',
|
|
367
|
+
cmdArgs,
|
|
368
|
+
errorMsg: '',
|
|
369
|
+
proc: null,
|
|
370
|
+
simTimer: null,
|
|
371
|
+
logLines: [],
|
|
372
|
+
};
|
|
373
|
+
this.items.push(item);
|
|
374
|
+
if (startImmediately) {
|
|
375
|
+
this.startItem(item);
|
|
376
|
+
}
|
|
377
|
+
this.onUpdate();
|
|
378
|
+
return item;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_updateFinalFileSize(item) {
|
|
382
|
+
if (item.destFile) {
|
|
383
|
+
try {
|
|
384
|
+
if (fs.existsSync(item.destFile)) {
|
|
385
|
+
const st = fs.statSync(item.destFile);
|
|
386
|
+
if (st.isFile() && st.size > 0) {
|
|
387
|
+
item.sizeStr = formatBytesToMB(st.size);
|
|
388
|
+
item.title = path.basename(item.destFile, path.extname(item.destFile));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const dir = path.dirname(item.destFile);
|
|
393
|
+
const stem = path.basename(item.destFile, path.extname(item.destFile));
|
|
394
|
+
if (fs.existsSync(dir)) {
|
|
395
|
+
const candidates = fs
|
|
396
|
+
.readdirSync(dir)
|
|
397
|
+
.filter((name) => path.basename(name, path.extname(name)) === stem)
|
|
398
|
+
.map((name) => {
|
|
399
|
+
const full = path.join(dir, name);
|
|
400
|
+
return { name, full, stat: fs.statSync(full) };
|
|
401
|
+
})
|
|
402
|
+
.filter((f) => f.stat.isFile() && !f.name.endsWith('.part') && !f.name.endsWith('.ytdl'))
|
|
403
|
+
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
404
|
+
if (candidates.length > 0) {
|
|
405
|
+
item.destFile = candidates[0].full;
|
|
406
|
+
item.sizeStr = formatBytesToMB(candidates[0].stat.size);
|
|
407
|
+
item.title = path.basename(candidates[0].full, path.extname(candidates[0].full));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} catch (_) {}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (item.outDir && fs.existsSync(item.outDir)) {
|
|
415
|
+
try {
|
|
416
|
+
const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
|
|
417
|
+
const recent = fs
|
|
418
|
+
.readdirSync(item.outDir)
|
|
419
|
+
.map((name) => {
|
|
420
|
+
const full = path.join(item.outDir, name);
|
|
421
|
+
return { name, full, stat: fs.statSync(full), ext: path.extname(name).toLowerCase() };
|
|
422
|
+
})
|
|
423
|
+
.filter((f) => f.stat.isFile() && exts.has(f.ext) && Date.now() - f.stat.mtimeMs < 60000)
|
|
424
|
+
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
425
|
+
if (recent.length > 0) {
|
|
426
|
+
item.destFile = recent[0].full;
|
|
427
|
+
item.sizeStr = formatBytesToMB(recent[0].stat.size);
|
|
428
|
+
item.title = path.basename(recent[0].full, path.extname(recent[0].full));
|
|
429
|
+
}
|
|
430
|
+
} catch (_) {}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
startItem(item) {
|
|
435
|
+
if (item.status === 'Downloading') return;
|
|
436
|
+
item.status = 'Downloading';
|
|
437
|
+
item.errorMsg = '';
|
|
438
|
+
|
|
439
|
+
const oIdx = item.cmdArgs.indexOf('-o');
|
|
440
|
+
if (oIdx !== -1 && item.cmdArgs[oIdx + 1]) {
|
|
441
|
+
const dir = path.dirname(item.cmdArgs[oIdx + 1]);
|
|
442
|
+
item.outDir = dir;
|
|
443
|
+
try {
|
|
444
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
445
|
+
} catch (_) {}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!this.ytdlpPath) {
|
|
449
|
+
if (this.simulateIfMissing) {
|
|
450
|
+
this._simulateDownload(item);
|
|
451
|
+
} else {
|
|
452
|
+
item.status = 'Error';
|
|
453
|
+
item.errorMsg = 'yt-dlp not found in PATH';
|
|
454
|
+
item.logLines.push('Error: yt-dlp binary not found. Install via brew/apt/winget or download standalone binary.');
|
|
455
|
+
}
|
|
456
|
+
this.onUpdate();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const childEnv = Object.assign({}, process.env, {
|
|
461
|
+
PYTHONIOENCODING: 'utf-8',
|
|
462
|
+
PYTHONUTF8: '1',
|
|
463
|
+
LANG: 'en_US.UTF-8',
|
|
464
|
+
LC_ALL: 'en_US.UTF-8',
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const proc = spawn(this.ytdlpPath, item.cmdArgs, {
|
|
468
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
469
|
+
env: childEnv,
|
|
470
|
+
});
|
|
471
|
+
item.proc = proc;
|
|
472
|
+
|
|
473
|
+
const handleLine = (line) => {
|
|
474
|
+
const trimmed = line.trim();
|
|
475
|
+
if (!trimmed) return;
|
|
476
|
+
item.logLines.push(trimmed);
|
|
477
|
+
if (item.logLines.length > 200) item.logLines.shift();
|
|
478
|
+
|
|
479
|
+
const mDest = this.destRe.exec(trimmed) || this.mergerRe.exec(trimmed) || this.alreadyRe.exec(trimmed);
|
|
480
|
+
if (mDest) {
|
|
481
|
+
const destPath = mDest[1].trim().replace(/^"|"$/g, '');
|
|
482
|
+
item.destFile = destPath;
|
|
483
|
+
item.title = path.basename(destPath, path.extname(destPath));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const mProg = this.progressRe.exec(trimmed);
|
|
487
|
+
if (mProg) {
|
|
488
|
+
item.progress = parseFloat(mProg[1]) || item.progress;
|
|
489
|
+
const rawSz = mProg[2] || '';
|
|
490
|
+
if (rawSz) {
|
|
491
|
+
const mibMatch = /^(\d+(?:\.\d+)?)MiB$/i.exec(rawSz);
|
|
492
|
+
const kibMatch = /^(\d+(?:\.\d+)?)KiB$/i.exec(rawSz);
|
|
493
|
+
if (mibMatch) {
|
|
494
|
+
const bytes = parseFloat(mibMatch[1]) * 1024 * 1024;
|
|
495
|
+
item.sizeStr = formatBytesToMB(bytes);
|
|
496
|
+
} else if (kibMatch) {
|
|
497
|
+
const bytes = parseFloat(kibMatch[1]) * 1024;
|
|
498
|
+
item.sizeStr = formatBytesToMB(bytes);
|
|
499
|
+
} else {
|
|
500
|
+
item.sizeStr = rawSz;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
item.speedStr = mProg[3] || '';
|
|
504
|
+
item.etaStr = mProg[4] || '';
|
|
505
|
+
}
|
|
506
|
+
this.onUpdate();
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
510
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
511
|
+
let stdoutBuf = '';
|
|
512
|
+
let stderrBuf = '';
|
|
513
|
+
|
|
514
|
+
proc.stdout.on('data', (chunk) => {
|
|
515
|
+
stdoutBuf += stdoutDecoder.write(chunk);
|
|
516
|
+
const parts = stdoutBuf.split(/[\r\n]+/);
|
|
517
|
+
stdoutBuf = parts.pop();
|
|
518
|
+
for (const p of parts) handleLine(p);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
proc.stderr.on('data', (chunk) => {
|
|
522
|
+
stderrBuf += stderrDecoder.write(chunk);
|
|
523
|
+
const parts = stderrBuf.split(/[\r\n]+/);
|
|
524
|
+
stderrBuf = parts.pop();
|
|
525
|
+
for (const p of parts) handleLine(p);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
proc.on('close', (code) => {
|
|
529
|
+
const remOut = stdoutBuf + stdoutDecoder.end();
|
|
530
|
+
const remErr = stderrBuf + stderrDecoder.end();
|
|
531
|
+
if (remOut.trim()) handleLine(remOut);
|
|
532
|
+
if (remErr.trim()) handleLine(remErr);
|
|
533
|
+
|
|
534
|
+
item.proc = null;
|
|
535
|
+
if (item.status === 'Cancelled' || item.status === 'Paused') {
|
|
536
|
+
this.onUpdate();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (code === 0) {
|
|
540
|
+
item.progress = 100;
|
|
541
|
+
item.status = 'Done';
|
|
542
|
+
this._updateFinalFileSize(item);
|
|
543
|
+
} else {
|
|
544
|
+
item.status = 'Error';
|
|
545
|
+
item.errorMsg = item.logLines[item.logLines.length - 1] || `Exit code ${code}`;
|
|
546
|
+
}
|
|
547
|
+
this.onUpdate();
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
pauseItem(item) {
|
|
552
|
+
if (item.status !== 'Downloading') return;
|
|
553
|
+
if (item.proc) {
|
|
554
|
+
try {
|
|
555
|
+
item.proc.kill('SIGSTOP');
|
|
556
|
+
item.status = 'Paused';
|
|
557
|
+
} catch (_) {
|
|
558
|
+
item.proc.kill('SIGTERM');
|
|
559
|
+
item.status = 'Paused';
|
|
560
|
+
}
|
|
561
|
+
} else if (item.simTimer) {
|
|
562
|
+
item.status = 'Paused';
|
|
563
|
+
}
|
|
564
|
+
this.onUpdate();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
resumeItem(item) {
|
|
568
|
+
if (item.status === 'Paused' && item.proc) {
|
|
569
|
+
try {
|
|
570
|
+
item.proc.kill('SIGCONT');
|
|
571
|
+
item.status = 'Downloading';
|
|
572
|
+
this.onUpdate();
|
|
573
|
+
return;
|
|
574
|
+
} catch (_) {}
|
|
575
|
+
}
|
|
576
|
+
if (item.status === 'Paused' && item.simTimer) {
|
|
577
|
+
item.status = 'Downloading';
|
|
578
|
+
this.onUpdate();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (['Paused', 'Queued', 'Error', 'Cancelled'].includes(item.status)) {
|
|
582
|
+
this.startItem(item);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
cancelItem(item) {
|
|
587
|
+
if (item.simTimer) {
|
|
588
|
+
clearInterval(item.simTimer);
|
|
589
|
+
item.simTimer = null;
|
|
590
|
+
}
|
|
591
|
+
if (item.proc) {
|
|
592
|
+
try {
|
|
593
|
+
item.proc.kill('SIGTERM');
|
|
594
|
+
} catch (_) {}
|
|
595
|
+
item.proc = null;
|
|
596
|
+
}
|
|
597
|
+
item.status = 'Cancelled';
|
|
598
|
+
this.onUpdate();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
removeItem(idx) {
|
|
602
|
+
if (idx >= 0 && idx < this.items.length) {
|
|
603
|
+
this.cancelItem(this.items[idx]);
|
|
604
|
+
this.items.splice(idx, 1);
|
|
605
|
+
this.items.forEach((it, i) => {
|
|
606
|
+
it.id = i + 1;
|
|
607
|
+
});
|
|
608
|
+
this.nextId = this.items.length + 1;
|
|
609
|
+
this.onUpdate();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
_simulateDownload(item) {
|
|
614
|
+
item.sizeStr = '8.40 MB';
|
|
615
|
+
item.simTimer = setInterval(() => {
|
|
616
|
+
if (item.status === 'Paused') return;
|
|
617
|
+
if (item.status === 'Cancelled') {
|
|
618
|
+
clearInterval(item.simTimer);
|
|
619
|
+
item.simTimer = null;
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
item.progress = Math.min(100, item.progress + 4);
|
|
623
|
+
item.speedStr = '3.5MiB/s';
|
|
624
|
+
item.etaStr = `00:${String(Math.max(0, Math.round((100 - item.progress) / 4))).padStart(2, '0')}`;
|
|
625
|
+
item.logLines.push(`[download] ${item.progress.toFixed(1)}% of ${item.sizeStr} at ${item.speedStr} ETA ${item.etaStr}`);
|
|
626
|
+
if (item.progress >= 100) {
|
|
627
|
+
clearInterval(item.simTimer);
|
|
628
|
+
item.simTimer = null;
|
|
629
|
+
item.status = 'Done';
|
|
630
|
+
}
|
|
631
|
+
this.onUpdate();
|
|
632
|
+
}, 120);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// Virtual Screen Buffer & Node.js TUI Engine
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
class ScreenBuffer {
|
|
641
|
+
constructor(cols, rows) {
|
|
642
|
+
this.cols = cols;
|
|
643
|
+
this.rows = rows;
|
|
644
|
+
this.chars = Array.from({ length: rows }, () => Array(cols).fill(' '));
|
|
645
|
+
this.styles = Array.from({ length: rows }, () => Array(cols).fill(ANSI.fgWhite));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
putStr(y, x, text, style = ANSI.fgWhite) {
|
|
649
|
+
if (y < 0 || y >= this.rows) return;
|
|
650
|
+
const str = String(text);
|
|
651
|
+
for (let i = 0; i < str.length; i++) {
|
|
652
|
+
const cx = x + i;
|
|
653
|
+
if (cx >= 0 && cx < this.cols - 1) {
|
|
654
|
+
this.chars[y][cx] = str[i];
|
|
655
|
+
this.styles[y][cx] = style;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
drawBox(y, x, h, w, title = '', style = ANSI.fgWhite, titleStyle = ANSI.bold + ANSI.fgCyan) {
|
|
661
|
+
if (h < 2 || w < 4) return;
|
|
662
|
+
this.putStr(y, x, '┌' + '─'.repeat(w - 2) + '┐', style);
|
|
663
|
+
for (let r = y + 1; r < y + h - 1; r++) {
|
|
664
|
+
this.putStr(r, x, '│', style);
|
|
665
|
+
this.putStr(r, x + w - 1, '│', style);
|
|
666
|
+
}
|
|
667
|
+
this.putStr(y + h - 1, x, '└' + '─'.repeat(w - 2) + '┘', style);
|
|
668
|
+
if (title) {
|
|
669
|
+
this.putStr(y, x + 2, ` ${title} `, titleStyle);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
renderToAnsi() {
|
|
674
|
+
let out = `${CSI}H`;
|
|
675
|
+
for (let r = 0; r < this.rows; r++) {
|
|
676
|
+
let curStyle = '';
|
|
677
|
+
let line = '';
|
|
678
|
+
for (let c = 0; c < this.cols - 1; c++) {
|
|
679
|
+
const st = this.styles[r][c];
|
|
680
|
+
if (st !== curStyle) {
|
|
681
|
+
line += ANSI.reset + st;
|
|
682
|
+
curStyle = st;
|
|
683
|
+
}
|
|
684
|
+
line += this.chars[r][c];
|
|
685
|
+
}
|
|
686
|
+
line += ANSI.reset;
|
|
687
|
+
if (r < this.rows - 1) line += '\r\n';
|
|
688
|
+
out += line;
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
class EvaNodeTUI {
|
|
695
|
+
constructor(opts, manager) {
|
|
696
|
+
this.opts = opts;
|
|
697
|
+
this.manager = manager;
|
|
698
|
+
this.manager.onUpdate = () => this.render();
|
|
699
|
+
|
|
700
|
+
this.activeTab = 1; // 1: Download, 2: Queue, 3: Library, 4: Settings, 5: About
|
|
701
|
+
this.focusId = 'url_input';
|
|
702
|
+
this.focusOrder = [];
|
|
703
|
+
this.hitboxes = [];
|
|
704
|
+
this.selectedQueueIdx = 0;
|
|
705
|
+
this.statusBanner = '';
|
|
706
|
+
|
|
707
|
+
// Inline modal prompt state
|
|
708
|
+
this.modal = null; // { label, value, cursor, onConfirm }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
start() {
|
|
712
|
+
if (process.stdin.isTTY) {
|
|
713
|
+
process.stdin.setRawMode(true);
|
|
714
|
+
}
|
|
715
|
+
process.stdin.resume();
|
|
716
|
+
process.stdin.setEncoding('utf8');
|
|
717
|
+
|
|
718
|
+
process.stdout.write(ANSI.altScreenOn + ANSI.hideCursor + ANSI.mouseOn + ANSI.clearScreen);
|
|
719
|
+
|
|
720
|
+
process.stdout.on('resize', () => this.render());
|
|
721
|
+
process.stdin.on('data', (data) => this.handleInput(data));
|
|
722
|
+
process.on('SIGINT', () => this.quit());
|
|
723
|
+
process.on('SIGTERM', () => this.quit());
|
|
724
|
+
|
|
725
|
+
this.render();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
quit() {
|
|
729
|
+
process.stdout.write(ANSI.mouseOff + ANSI.showCursor + ANSI.altScreenOff + ANSI.reset + '\n');
|
|
730
|
+
process.exit(0);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
|
|
734
|
+
this.focusOrder.push(id);
|
|
735
|
+
const isFocused = this.focusId === id;
|
|
736
|
+
|
|
737
|
+
// Префикс и его цвет
|
|
738
|
+
let prefix = '';
|
|
739
|
+
let prefixStyle = ANSI.fgWhite;
|
|
740
|
+
|
|
741
|
+
if (checked !== null) {
|
|
742
|
+
prefix = checked ? '[x] ' : '[ ] ';
|
|
743
|
+
prefixStyle = checked
|
|
744
|
+
? ANSI.fgGreen + ANSI.bold
|
|
745
|
+
: ANSI.dim + ANSI.fgWhite;
|
|
746
|
+
} else if (radio !== null) {
|
|
747
|
+
prefix = radio ? '(•) ' : '( ) ';
|
|
748
|
+
prefixStyle = radio
|
|
749
|
+
? ANSI.fgCyan + ANSI.bold
|
|
750
|
+
: ANSI.dim + ANSI.fgWhite;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Стиль основного текста
|
|
754
|
+
let style = ANSI.fgGray;
|
|
755
|
+
|
|
756
|
+
if (isFocused) {
|
|
757
|
+
style = isCta
|
|
758
|
+
? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
|
|
759
|
+
: ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
|
|
760
|
+
} else if (isCta) {
|
|
761
|
+
style = id === 'url_input'
|
|
762
|
+
? ANSI.fgWhite + ANSI.bold
|
|
763
|
+
: id === 'btn_add_queue'
|
|
764
|
+
? ANSI.fgGreen + ANSI.bold
|
|
765
|
+
: ANSI.fgCyan + ANSI.bold;
|
|
766
|
+
} else if (checked || radio) {
|
|
767
|
+
style = ANSI.fgOrange + ANSI.bold;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Вывод префикса с раздельными цветами
|
|
771
|
+
if (checked !== null) {
|
|
772
|
+
scr.putStr(y, x, '[', ANSI.fgGray);
|
|
773
|
+
scr.putStr(y, x + 1, checked ? 'x' : ' ', checked ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
|
|
774
|
+
scr.putStr(y, x + 2, '] ', ANSI.fgGray);
|
|
775
|
+
} else if (radio !== null) {
|
|
776
|
+
scr.putStr(y, x, '(', ANSI.fgGray);
|
|
777
|
+
scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
|
|
778
|
+
scr.putStr(y, x + 2, ') ', ANSI.fgGray);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Расчёт доступной ширины текста
|
|
782
|
+
const availableWidth = maxWidth > 0
|
|
783
|
+
? Math.max(0, maxWidth - prefix.length)
|
|
784
|
+
: Infinity;
|
|
785
|
+
|
|
786
|
+
let visibleLabel = label;
|
|
787
|
+
|
|
788
|
+
if (visibleLabel.length > availableWidth) {
|
|
789
|
+
visibleLabel = availableWidth > 0
|
|
790
|
+
? visibleLabel.slice(0, availableWidth - 1) + '…'
|
|
791
|
+
: '';
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Вывод основного текста
|
|
795
|
+
const textX = x + (prefix ? prefix.length : 0);
|
|
796
|
+
const editMarker = '[ред.]';
|
|
797
|
+
const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
|
|
798
|
+
if (!isFocused && editMarkerIndex >= 0) {
|
|
799
|
+
const folderLabel = 'Папка:';
|
|
800
|
+
const folderLabelIndex = visibleLabel.indexOf(folderLabel);
|
|
801
|
+
if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
|
|
802
|
+
scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
|
|
803
|
+
scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
|
|
804
|
+
const afterFolderIndex = folderLabelIndex + folderLabel.length;
|
|
805
|
+
scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
|
|
806
|
+
} else {
|
|
807
|
+
scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
|
|
808
|
+
}
|
|
809
|
+
scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
|
|
810
|
+
scr.putStr(y, textX + editMarkerIndex + editMarker.length, visibleLabel.slice(editMarkerIndex + editMarker.length), style);
|
|
811
|
+
} else {
|
|
812
|
+
scr.putStr(y, textX, visibleLabel, style);
|
|
813
|
+
}
|
|
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
|
+
|
|
829
|
+
|
|
830
|
+
openPrompt(label, currentVal, onConfirm) {
|
|
831
|
+
const val = String(currentVal || '');
|
|
832
|
+
this.modal = {
|
|
833
|
+
label,
|
|
834
|
+
value: val,
|
|
835
|
+
cursor: val.length,
|
|
836
|
+
onConfirm,
|
|
837
|
+
};
|
|
838
|
+
this.render();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// -------------------------------------------------------------------------
|
|
842
|
+
// Rendering
|
|
843
|
+
// -------------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
render() {
|
|
846
|
+
const cols = process.stdout.columns || 100;
|
|
847
|
+
const rows = process.stdout.rows || 28;
|
|
848
|
+
const scr = new ScreenBuffer(cols, rows);
|
|
849
|
+
|
|
850
|
+
this.focusOrder = [];
|
|
851
|
+
this.hitboxes = [];
|
|
852
|
+
|
|
853
|
+
if (rows < 22 || cols < 76) {
|
|
854
|
+
scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
|
|
855
|
+
process.stdout.write(scr.renderToAnsi());
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 1. Header Logo & Tagline
|
|
860
|
+
ASCII_LOGO.forEach((line, i) => {
|
|
861
|
+
scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
|
|
862
|
+
});
|
|
863
|
+
scr.putStr(7, 2, 'EVA MEDIA DOWNLOADER — by .maxon', ANSI.fgCyan);
|
|
864
|
+
scr.putStr(2, cols - APP_VERSION.length - 3, APP_VERSION, ANSI.fgWhite);
|
|
865
|
+
if (cols >= 90) {
|
|
866
|
+
const tagline = 'Music. Videos. Your collection.';
|
|
867
|
+
scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
|
|
868
|
+
scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 2. Left Sidebar Navigation (Strictly 1-cell narrow CP437/WGL4 characters so borders NEVER shift)
|
|
872
|
+
const sidebarW = 19;
|
|
873
|
+
const navItems = [
|
|
874
|
+
{ tab: 1, label: '≡ General' },
|
|
875
|
+
{ tab: 2, label: `↓ Download (${this.manager.items.length})` },
|
|
876
|
+
{ tab: 3, label: '♫ Library' },
|
|
877
|
+
{ tab: 4, label: '* Settings' },
|
|
878
|
+
{ tab: 5, label: 'i About' },
|
|
879
|
+
];
|
|
880
|
+
|
|
881
|
+
navItems.forEach((nav, idx) => {
|
|
882
|
+
const r = 9 + idx * 2;
|
|
883
|
+
const isActive = this.activeTab === nav.tab;
|
|
884
|
+
const padded = ` ${nav.label.padEnd(sidebarW - 3, ' ')} `;
|
|
885
|
+
const st = isActive ? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold : ANSI.fgWhite;
|
|
886
|
+
scr.putStr(r, 2, padded, st);
|
|
887
|
+
this.hitboxes.push({
|
|
888
|
+
id: `tab_${nav.tab}`,
|
|
889
|
+
y: r,
|
|
890
|
+
x1: 2,
|
|
891
|
+
x2: 2 + padded.length - 1,
|
|
892
|
+
action: () => {
|
|
893
|
+
this.activeTab = nav.tab;
|
|
894
|
+
this.render();
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
for (let r = 8; r < rows - 2; r++) {
|
|
900
|
+
scr.putStr(r, sidebarW + 1, '│', ANSI.fgWhite);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// 3. Main Content Area
|
|
904
|
+
const mainX = sidebarW + 3;
|
|
905
|
+
const mainW = cols - mainX - 2;
|
|
906
|
+
|
|
907
|
+
if (this.activeTab === 1) this._renderDownloadTab(scr, mainX, mainW, rows);
|
|
908
|
+
else if (this.activeTab === 2) this._renderQueueTab(scr, mainX, mainW, rows);
|
|
909
|
+
else if (this.activeTab === 3) this._renderLibraryTab(scr, mainX, mainW, rows);
|
|
910
|
+
else if (this.activeTab === 4) this._renderSettingsTab(scr, mainX, mainW, rows);
|
|
911
|
+
else if (this.activeTab === 5) this._renderAboutTab(scr, mainX, mainW, rows);
|
|
912
|
+
|
|
913
|
+
if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
|
|
914
|
+
this.focusId = this.focusOrder[0];
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// 4. Bottom Status Bar with Live Queue Counter & Progress Bar (or Modal Input)
|
|
918
|
+
if (this.modal) {
|
|
919
|
+
const prefix = ` * ${this.modal.label}: `;
|
|
920
|
+
scr.putStr(rows - 2, 0, prefix, ANSI.bgCyan + ANSI.fgBlack + ANSI.bold);
|
|
921
|
+
scr.putStr(rows - 2, prefix.length, this.modal.value.padEnd(Math.max(1, cols - prefix.length - 1), ' '), ANSI.fgYellow + ANSI.bold);
|
|
922
|
+
scr.putStr(rows - 1, 0, ' [Enter] Применить [Esc] Отмена [Ctrl+U] Очистить '.padEnd(cols - 1, ' '), ANSI.bgWhite + ANSI.fgBlack);
|
|
923
|
+
} else {
|
|
924
|
+
const footerLeft = 'EVA.md • yt-dlp';
|
|
925
|
+
scr.putStr(rows - 1, 2, footerLeft, ANSI.fgCyan + ANSI.bold);
|
|
926
|
+
|
|
927
|
+
const items = this.manager.items;
|
|
928
|
+
const activeItems = items.filter((i) => i.status === 'Downloading');
|
|
929
|
+
const doneCnt = items.filter((i) => i.status === 'Done').length;
|
|
930
|
+
|
|
931
|
+
let pct = 0;
|
|
932
|
+
if (activeItems.length > 0) {
|
|
933
|
+
const sum = activeItems.reduce((acc, i) => acc + (i.progress || 0), 0);
|
|
934
|
+
pct = Math.round(sum / activeItems.length);
|
|
935
|
+
} else if (items.length > 0 && doneCnt === items.length) {
|
|
936
|
+
pct = 100;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const barW = 12;
|
|
940
|
+
const filled = Math.round((pct / 100) * barW);
|
|
941
|
+
const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
|
|
942
|
+
|
|
943
|
+
const queueStatusStr = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
|
|
944
|
+
const qStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
|
|
945
|
+
const qStartX = cols - queueStatusStr.length - 0;
|
|
946
|
+
scr.putStr(rows - 1, qStartX, queueStatusStr, qStyle);
|
|
947
|
+
|
|
948
|
+
const afterQueueX = qStartX + queueStatusStr.length + 2;
|
|
949
|
+
if (this.statusBanner && afterQueueX < cols - 10) {
|
|
950
|
+
scr.putStr(rows - 1, afterQueueX, `│ ${this.statusBanner}`.slice(0, cols - afterQueueX - 2), ANSI.fgCyan);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
process.stdout.write(scr.renderToAnsi());
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// -------------------------------------------------------------------------
|
|
958
|
+
// Tab 1: Compact Main Download Screen (Word-wrapped CLI, no bottom queue)
|
|
959
|
+
// -------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
_renderDownloadTab(scr, x, w, rows) {
|
|
962
|
+
let y = 8;
|
|
963
|
+
|
|
964
|
+
// --- Box 1: Select a source (1-9) & URL input ---
|
|
965
|
+
scr.drawBox(y, x, 7, w, 'Select a source');
|
|
966
|
+
const colWidth = Math.floor(w / 3);
|
|
967
|
+
const col1X = x + 2;
|
|
968
|
+
const col2X = x + colWidth;
|
|
969
|
+
const col3X = x + colWidth * 2;
|
|
970
|
+
|
|
971
|
+
SOURCES.forEach((s) => {
|
|
972
|
+
const colIndex = Math.floor((s.id - 1) / 3);
|
|
973
|
+
const rowIndex = (s.id - 1) % 3;
|
|
974
|
+
|
|
975
|
+
const sy = y + 1 + rowIndex;
|
|
976
|
+
const sx = colIndex === 0 ? col1X : colIndex === 1 ? col2X : col3X;
|
|
977
|
+
|
|
978
|
+
this.registerWidget(
|
|
979
|
+
scr,
|
|
980
|
+
`src_${s.id}`,
|
|
981
|
+
sy,
|
|
982
|
+
sx,
|
|
983
|
+
`${s.code} ${s.icon} ${s.name}`,
|
|
984
|
+
() => this._selectSource(s.id),
|
|
985
|
+
{ radio: this.opts.sourceId === s.id, maxWidth: Math.floor(w / 2) - 3 }
|
|
986
|
+
);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
scr.putStr(y + 4, x + 0, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
|
|
990
|
+
const srcObj = SOURCES.find((s) => s.id === this.opts.sourceId) || SOURCES[0];
|
|
991
|
+
const urlDisp = this.opts.url ? this.opts.url : `(нажмите 'u' или кликните для ввода URL — ${srcObj.hint})`;
|
|
992
|
+
this.registerWidget(
|
|
993
|
+
scr,
|
|
994
|
+
'url_input',
|
|
995
|
+
y + 5,
|
|
996
|
+
x + 2,
|
|
997
|
+
`URL > ${urlDisp}`,
|
|
998
|
+
() => this.openPrompt('Вставьте ссылку на трек или плейлист', this.opts.url, (val) => this._setUrl(val)),
|
|
999
|
+
{ isCta: true, maxWidth: w - 4 }
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
y += 7;
|
|
1003
|
+
|
|
1004
|
+
// --- Box 2: Download Options & yt-dlp Flags ---
|
|
1005
|
+
const box2H = 11;
|
|
1006
|
+
scr.drawBox(y, x, box2H, w, 'Download Options Flags');
|
|
1007
|
+
const c1 = x + 2;
|
|
1008
|
+
|
|
1009
|
+
// Row 1: Audio (-x, metadata, thumbnail)
|
|
1010
|
+
scr.putStr(y + 1, c1, 'АУДИО:', ANSI.fgCyan + ANSI.bold);
|
|
1011
|
+
let ax = c1 + 7;
|
|
1012
|
+
ax =
|
|
1013
|
+
this.registerWidget(
|
|
1014
|
+
scr,
|
|
1015
|
+
'flag_x',
|
|
1016
|
+
y + 1,
|
|
1017
|
+
ax,
|
|
1018
|
+
'Только аудио',
|
|
1019
|
+
() => this._toggle('extractAudio'),
|
|
1020
|
+
{ checked: this.opts.extractAudio }
|
|
1021
|
+
) + 2;
|
|
1022
|
+
ax =
|
|
1023
|
+
this.registerWidget(
|
|
1024
|
+
scr,
|
|
1025
|
+
'flag_meta',
|
|
1026
|
+
y + 1,
|
|
1027
|
+
ax,
|
|
1028
|
+
'Встроить метаданные',
|
|
1029
|
+
() => this._toggle('embedMetadata'),
|
|
1030
|
+
{ checked: this.opts.embedMetadata }
|
|
1031
|
+
) + 2;
|
|
1032
|
+
this.registerWidget(
|
|
1033
|
+
scr,
|
|
1034
|
+
'flag_thumb',
|
|
1035
|
+
y + 1,
|
|
1036
|
+
ax,
|
|
1037
|
+
'Встроить обложку',
|
|
1038
|
+
() => this._toggle('embedThumbnail'),
|
|
1039
|
+
{ checked: this.opts.embedThumbnail, maxWidth: x + w - ax - 2 }
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
// Row 2: Format (--audio-format)
|
|
1043
|
+
scr.putStr(y + 2, c1, 'ФОРМАТ:', ANSI.fgCyan + ANSI.bold);
|
|
1044
|
+
let fx = c1 + 8;
|
|
1045
|
+
AUDIO_FORMATS.forEach((fmt) => {
|
|
1046
|
+
fx =
|
|
1047
|
+
this.registerWidget(
|
|
1048
|
+
scr,
|
|
1049
|
+
`fmt_${fmt}`,
|
|
1050
|
+
y + 2,
|
|
1051
|
+
fx,
|
|
1052
|
+
fmt,
|
|
1053
|
+
() => {
|
|
1054
|
+
this.opts.audioFormat = fmt;
|
|
1055
|
+
this.opts.extractAudio = true;
|
|
1056
|
+
this.opts.bestVideoMerge = false;
|
|
1057
|
+
this.statusBanner = `Формат: --audio-format ${fmt}`;
|
|
1058
|
+
this.render();
|
|
1059
|
+
},
|
|
1060
|
+
{ radio: this.opts.audioFormat === fmt }
|
|
1061
|
+
) + 2;
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// Row 3: Quality (--audio-quality)
|
|
1065
|
+
scr.putStr(y + 3, c1, 'БИТРЕЙТ:', ANSI.fgCyan + ANSI.bold);
|
|
1066
|
+
let qx = c1 + 9;
|
|
1067
|
+
AUDIO_QUALITIES.forEach((q) => {
|
|
1068
|
+
qx =
|
|
1069
|
+
this.registerWidget(
|
|
1070
|
+
scr,
|
|
1071
|
+
`qual_${q.val}`,
|
|
1072
|
+
y + 3,
|
|
1073
|
+
qx,
|
|
1074
|
+
q.label,
|
|
1075
|
+
() => {
|
|
1076
|
+
this.opts.audioQuality = q.val;
|
|
1077
|
+
this.statusBanner = `Качество: --audio-quality ${q.val}`;
|
|
1078
|
+
this.render();
|
|
1079
|
+
},
|
|
1080
|
+
{ radio: this.opts.audioQuality === q.val }
|
|
1081
|
+
) + 2;
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// Row 4: Video quality and subtitle file
|
|
1085
|
+
scr.putStr(y + 4, c1, 'ВИДЕО:', ANSI.fgCyan + ANSI.bold);
|
|
1086
|
+
let vx = c1 + 7;
|
|
1087
|
+
vx =
|
|
1088
|
+
this.registerWidget(
|
|
1089
|
+
scr,
|
|
1090
|
+
'flag_bvbab',
|
|
1091
|
+
y + 4,
|
|
1092
|
+
vx,
|
|
1093
|
+
'Видео+Аудио',
|
|
1094
|
+
() => {
|
|
1095
|
+
this.opts.bestVideoMerge = !this.opts.bestVideoMerge;
|
|
1096
|
+
this.opts.extractAudio = !this.opts.bestVideoMerge;
|
|
1097
|
+
this.statusBanner = this.opts.bestVideoMerge ? 'Режим видео -f "bv*+ba/b"' : 'Режим аудио -x';
|
|
1098
|
+
this.render();
|
|
1099
|
+
},
|
|
1100
|
+
{ checked: this.opts.bestVideoMerge }
|
|
1101
|
+
) + 2;
|
|
1102
|
+
vx =
|
|
1103
|
+
this.registerWidget(scr, 'flag_wsubs', y + 4, vx, 'Файл субтитров', () => this._toggle('writeSubs'), {
|
|
1104
|
+
checked: this.opts.writeSubs,
|
|
1105
|
+
}) + 2;
|
|
1106
|
+
let mediaOptionsX =
|
|
1107
|
+
this.registerWidget(scr, 'flag_esubs', y + 5, c1 + 7, 'Встроить субтитры', () => this._toggle('embedSubs'), {
|
|
1108
|
+
checked: this.opts.embedSubs,
|
|
1109
|
+
}) + 2;
|
|
1110
|
+
this.registerWidget(scr, 'flag_wthumb', y + 5, mediaOptionsX, 'Скачать обложку', () => this._toggle('writeThumbnail'), {
|
|
1111
|
+
checked: this.opts.writeThumbnail,
|
|
1112
|
+
maxWidth: x + w - mediaOptionsX - 2,
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// Row 6: Naming & Archive (-o & --download-archive)
|
|
1116
|
+
scr.putStr(y + 6, c1, 'ФАЙЛЫ:', ANSI.fgCyan + ANSI.bold);
|
|
1117
|
+
const maxTplLen = Math.max(14, Math.floor((w - 20) / 4));
|
|
1118
|
+
const shortTpl =
|
|
1119
|
+
this.opts.outputTemplate.length > maxTplLen
|
|
1120
|
+
? this.opts.outputTemplate.slice(0, maxTplLen - 1) + '…'
|
|
1121
|
+
: this.opts.outputTemplate;
|
|
1122
|
+
let nx =
|
|
1123
|
+
this.registerWidget(
|
|
1124
|
+
scr,
|
|
1125
|
+
'flag_otpl',
|
|
1126
|
+
y + 6,
|
|
1127
|
+
c1 + 9,
|
|
1128
|
+
`-o "${shortTpl}"`,
|
|
1129
|
+
() => this._toggle('usePlaylistTemplate'),
|
|
1130
|
+
{ checked: this.opts.usePlaylistTemplate }
|
|
1131
|
+
) + 1;
|
|
1132
|
+
nx =
|
|
1133
|
+
this.registerWidget(scr, 'edit_otpl', y + 6, nx, '[ред.]', () =>
|
|
1134
|
+
this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
|
|
1135
|
+
this.opts.outputTemplate = v || '%(title)s.%(ext)s';
|
|
1136
|
+
})
|
|
1137
|
+
) + 3;
|
|
1138
|
+
|
|
1139
|
+
let arx =
|
|
1140
|
+
this.registerWidget(
|
|
1141
|
+
scr,
|
|
1142
|
+
'flag_archive',
|
|
1143
|
+
y + 6,
|
|
1144
|
+
nx,
|
|
1145
|
+
`История загрузки ${this.opts.archiveFile}`,
|
|
1146
|
+
() => this._toggle('useDownloadArchive'),
|
|
1147
|
+
{ checked: this.opts.useDownloadArchive }
|
|
1148
|
+
) + 1;
|
|
1149
|
+
this.registerWidget(scr, 'edit_archive', y + 6, arx, '[ред.]', () =>
|
|
1150
|
+
this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
|
|
1151
|
+
this.opts.archiveFile = v || 'archive.txt';
|
|
1152
|
+
})
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
// Row 7: Playlists (--playlist-items & --playlist-reverse)
|
|
1156
|
+
scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
|
|
1157
|
+
let px =
|
|
1158
|
+
this.registerWidget(
|
|
1159
|
+
scr,
|
|
1160
|
+
'flag_plitems',
|
|
1161
|
+
y + 7,
|
|
1162
|
+
c1 + 9,
|
|
1163
|
+
`Выбрать файлы ${this.opts.playlistItems}`,
|
|
1164
|
+
() => this._toggle('usePlaylistItems'),
|
|
1165
|
+
{ checked: this.opts.usePlaylistItems }
|
|
1166
|
+
) + 1;
|
|
1167
|
+
px =
|
|
1168
|
+
this.registerWidget(scr, 'edit_plitems', y + 7, px, '[ред.]', () =>
|
|
1169
|
+
this.openPrompt('Номера треков (--playlist-items, напр. 1-5)', this.opts.playlistItems, (v) => {
|
|
1170
|
+
this.opts.playlistItems = v || '1-5';
|
|
1171
|
+
})
|
|
1172
|
+
) + 3;
|
|
1173
|
+
|
|
1174
|
+
this.registerWidget(
|
|
1175
|
+
scr,
|
|
1176
|
+
'flag_plrev',
|
|
1177
|
+
y + 7,
|
|
1178
|
+
px,
|
|
1179
|
+
'Загрузить плейлист с конца',
|
|
1180
|
+
() => this._toggle('playlistReverse'),
|
|
1181
|
+
{ checked: this.opts.playlistReverse, maxWidth: x + w - px - 2 }
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
// Row 9: Output directory & Add to Queue CTA button
|
|
1185
|
+
const maxDirLen = Math.max(16, w - 48);
|
|
1186
|
+
const shortDir =
|
|
1187
|
+
this.opts.outputDir.length > maxDirLen
|
|
1188
|
+
? '…' + this.opts.outputDir.slice(-(maxDirLen - 1))
|
|
1189
|
+
: this.opts.outputDir;
|
|
1190
|
+
let dx =
|
|
1191
|
+
this.registerWidget(scr, 'edit_outdir', y + 9, c1, `Папка: ${shortDir} [ред.]`, () =>
|
|
1192
|
+
this.openPrompt('Папка сохранения', this.opts.outputDir, (v) => {
|
|
1193
|
+
this.opts.outputDir = v || path.join(os.homedir(), 'Downloads');
|
|
1194
|
+
})
|
|
1195
|
+
) + 3;
|
|
1196
|
+
|
|
1197
|
+
const ctaLabel = 'Скачать (Enter / a)';
|
|
1198
|
+
const ctaX = Math.max(dx, x + w - ctaLabel.length - 3);
|
|
1199
|
+
this.registerWidget(scr, 'btn_add_queue', y + 9, ctaX, ctaLabel, () => this._actionAddToQueue(), {
|
|
1200
|
+
isCta: true,
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
y += box2H;
|
|
1204
|
+
|
|
1205
|
+
// Word-wrapped CLI Preview Lines (never overflows the window width)
|
|
1206
|
+
const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
|
|
1207
|
+
const wrappedCli = wrapTextByWords(fullCliText, w - 2);
|
|
1208
|
+
const availCliRows = Math.max(1, rows - 2 - y);
|
|
1209
|
+
wrappedCli.slice(0, availCliRows).forEach((line, idx) => {
|
|
1210
|
+
scr.putStr(y + idx, x + 1, line, ANSI.fgYellow);
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// -------------------------------------------------------------------------
|
|
1215
|
+
// Tab 2: Queue Tab (Full Queue Table + Live yt-dlp Output Log)
|
|
1216
|
+
// -------------------------------------------------------------------------
|
|
1217
|
+
|
|
1218
|
+
_renderQueueBox(scr, y, x, h, w) {
|
|
1219
|
+
scr.drawBox(y, x, h, w, 'Download Queue');
|
|
1220
|
+
const titleColW = Math.max(18, w - 42);
|
|
1221
|
+
const hdr = ` # ${'Title'.padEnd(titleColW)} ${'Format'.padEnd(7)} ${'Size'.padEnd(10)} Status`;
|
|
1222
|
+
scr.putStr(y + 1, x + 2, hdr.slice(0, w - 4), ANSI.fgCyan + ANSI.bold);
|
|
1223
|
+
scr.putStr(y + 2, x + 1, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
|
|
1224
|
+
|
|
1225
|
+
const items = this.manager.items;
|
|
1226
|
+
if (this.selectedQueueIdx >= items.length) {
|
|
1227
|
+
this.selectedQueueIdx = Math.max(0, items.length - 1);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const listRows = Math.max(1, h - 5);
|
|
1231
|
+
if (items.length === 0) {
|
|
1232
|
+
scr.putStr(y + 3, x + 4, 'Очередь пуста. Добавьте трек во вкладке [1] Download.', ANSI.fgWhite);
|
|
1233
|
+
} else {
|
|
1234
|
+
const startI = Math.max(0, this.selectedQueueIdx - listRows + 1);
|
|
1235
|
+
const slice = items.slice(startI, startI + listRows);
|
|
1236
|
+
slice.forEach((it, rowOffset) => {
|
|
1237
|
+
const idx = startI + rowOffset;
|
|
1238
|
+
const ry = y + 3 + rowOffset;
|
|
1239
|
+
const isSel = idx === this.selectedQueueIdx;
|
|
1240
|
+
const pointer = isSel ? '►' : ' ';
|
|
1241
|
+
const barW = 10;
|
|
1242
|
+
const filled = Math.round((it.progress / 100) * barW);
|
|
1243
|
+
const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
|
|
1244
|
+
const pct = `${String(Math.round(it.progress)).padStart(3, ' ')}%`;
|
|
1245
|
+
let statusText = `[${bar}] ${pct} ${it.status}`;
|
|
1246
|
+
if (it.status === 'Error' && it.errorMsg) {
|
|
1247
|
+
statusText = `ERR: ${it.errorMsg.slice(0, 18)}`;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
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
|
+
const st = isSel
|
|
1255
|
+
? ANSI.bgWhite + ANSI.fgBlack + ANSI.bold
|
|
1256
|
+
: it.status === 'Done'
|
|
1257
|
+
? ANSI.fgGreen
|
|
1258
|
+
: it.status === 'Error'
|
|
1259
|
+
? ANSI.fgRed
|
|
1260
|
+
: ANSI.fgWhite;
|
|
1261
|
+
|
|
1262
|
+
scr.putStr(ry, x + 2, line.slice(0, w - 4), st);
|
|
1263
|
+
this.hitboxes.push({
|
|
1264
|
+
id: `qitem_${idx}`,
|
|
1265
|
+
y: ry,
|
|
1266
|
+
x1: x + 2,
|
|
1267
|
+
x2: x + w - 3,
|
|
1268
|
+
action: () => {
|
|
1269
|
+
this.selectedQueueIdx = idx;
|
|
1270
|
+
this.render();
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const ctrlY = y + h - 2;
|
|
1277
|
+
scr.putStr(ctrlY - 1, x + 1, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
|
|
1278
|
+
let cx = x + 3;
|
|
1279
|
+
cx = this.registerWidget(scr, 'q_resume', ctrlY, cx, '[r] Resume', () => this._qAction('resume')) + 2;
|
|
1280
|
+
cx = this.registerWidget(scr, 'q_pause', ctrlY, cx, '[p] Pause', () => this._qAction('pause')) + 2;
|
|
1281
|
+
cx = this.registerWidget(scr, 'q_cancel', ctrlY, cx, '[c] Cancel', () => this._qAction('cancel')) + 2;
|
|
1282
|
+
cx = this.registerWidget(scr, 'q_remove', ctrlY, cx, '[d] Remove', () => this._qAction('remove')) + 2;
|
|
1283
|
+
const activeCnt = items.filter((i) => i.status === 'Downloading').length;
|
|
1284
|
+
scr.putStr(ctrlY, cx, `│ Active: ${activeCnt}/${items.length}`.slice(0, Math.max(0, x + w - cx - 2)), ANSI.fgCyan);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
_renderQueueTab(scr, x, w, rows) {
|
|
1288
|
+
const queueH = Math.max(8, rows - 16);
|
|
1289
|
+
this._renderQueueBox(scr, 8, x, queueH, w);
|
|
1290
|
+
const logY = 8 + queueH;
|
|
1291
|
+
const logH = Math.max(4, rows - logY - 1);
|
|
1292
|
+
scr.drawBox(logY, x, logH, w, 'Selected Task yt-dlp Output Log');
|
|
1293
|
+
const it = this.manager.items[this.selectedQueueIdx];
|
|
1294
|
+
if (it) {
|
|
1295
|
+
scr.putStr(logY + 1, x + 2, `CMD: yt-dlp ${it.cmdArgs.map(shellQuote).join(' ')}`.slice(0, w - 4), ANSI.fgYellow);
|
|
1296
|
+
const maxLogLines = Math.max(1, logH - 3);
|
|
1297
|
+
it.logLines.slice(-maxLogLines).forEach((l, i) => {
|
|
1298
|
+
scr.putStr(logY + 2 + i, x + 2, l.slice(0, w - 4), ANSI.fgWhite);
|
|
1299
|
+
});
|
|
1300
|
+
} else {
|
|
1301
|
+
scr.putStr(logY + 2, x + 2, 'Выберите задачу в очереди для просмотра лога yt-dlp.', ANSI.fgWhite);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// -------------------------------------------------------------------------
|
|
1306
|
+
// Tab 3: Library Tab
|
|
1307
|
+
// -------------------------------------------------------------------------
|
|
1308
|
+
|
|
1309
|
+
_renderLibraryTab(scr, x, w, rows) {
|
|
1310
|
+
const h = rows - 9;
|
|
1311
|
+
scr.drawBox(8, x, h, w, `Downloaded Media Library (${this.opts.outputDir})`);
|
|
1312
|
+
const outDir = this.opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
|
|
1313
|
+
const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
|
|
1314
|
+
let files = [];
|
|
1315
|
+
try {
|
|
1316
|
+
if (fs.existsSync(outDir)) {
|
|
1317
|
+
files = fs
|
|
1318
|
+
.readdirSync(outDir)
|
|
1319
|
+
.map((name) => {
|
|
1320
|
+
const full = path.join(outDir, name);
|
|
1321
|
+
const stat = fs.statSync(full);
|
|
1322
|
+
return { name, full, stat, ext: path.extname(name).toLowerCase() };
|
|
1323
|
+
})
|
|
1324
|
+
.filter((f) => f.stat.isFile() && exts.has(f.ext))
|
|
1325
|
+
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
1326
|
+
}
|
|
1327
|
+
} catch (_) {}
|
|
1328
|
+
|
|
1329
|
+
const nameColW = Math.max(20, w - 28);
|
|
1330
|
+
if (files.length === 0) {
|
|
1331
|
+
scr.putStr(10, x + 3, `В папке ${outDir} пока нет скачанных медиафайлов.`.slice(0, w - 6), ANSI.fgWhite);
|
|
1332
|
+
} else {
|
|
1333
|
+
scr.putStr(
|
|
1334
|
+
10,
|
|
1335
|
+
x + 3,
|
|
1336
|
+
`${'#'.padEnd(4)} ${'Имя файла'.padEnd(nameColW)} ${'Формат'.padEnd(7)} Размер`.slice(0, w - 6),
|
|
1337
|
+
ANSI.fgCyan + ANSI.bold
|
|
1338
|
+
);
|
|
1339
|
+
files.slice(0, h - 5).forEach((f, i) => {
|
|
1340
|
+
const mb = formatBytesToMB(f.stat.size);
|
|
1341
|
+
const line = `${String(i + 1).padEnd(4)} ${f.name.padEnd(nameColW).slice(0, nameColW)} ${f.ext
|
|
1342
|
+
.slice(1)
|
|
1343
|
+
.padEnd(7)} ${mb}`;
|
|
1344
|
+
scr.putStr(12 + i, x + 3, line.slice(0, w - 6), ANSI.fgWhite);
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// -------------------------------------------------------------------------
|
|
1350
|
+
// Tab 4: Settings Tab (Network & Bypass flags + Naming Presets)
|
|
1351
|
+
// -------------------------------------------------------------------------
|
|
1352
|
+
|
|
1353
|
+
_renderSettingsTab(scr, x, w, rows) {
|
|
1354
|
+
const h = rows - 9;
|
|
1355
|
+
scr.drawBox(8, x, h, w, 'Settings & Network Options');
|
|
1356
|
+
const c1 = x + 3;
|
|
1357
|
+
|
|
1358
|
+
scr.putStr(10, c1, 'СЕТЬ И ОБХОД БЛОКИРОВОК:', ANSI.fgCyan + ANSI.bold);
|
|
1359
|
+
this.registerWidget(
|
|
1360
|
+
scr,
|
|
1361
|
+
'flag_ipv4',
|
|
1362
|
+
11,
|
|
1363
|
+
c1 + 2,
|
|
1364
|
+
'Принудительный IPv4 (Решает ошибки DNS)',
|
|
1365
|
+
() => this._toggle('forceIpv4'),
|
|
1366
|
+
{ checked: this.opts.forceIpv4, maxWidth: w - 8 }
|
|
1367
|
+
);
|
|
1368
|
+
|
|
1369
|
+
let px =
|
|
1370
|
+
this.registerWidget(
|
|
1371
|
+
scr,
|
|
1372
|
+
'flag_proxy',
|
|
1373
|
+
12,
|
|
1374
|
+
c1 + 2,
|
|
1375
|
+
`Прокси ${this.opts.proxyUrl}`,
|
|
1376
|
+
() => this._toggle('useProxy'),
|
|
1377
|
+
{ checked: this.opts.useProxy }
|
|
1378
|
+
) + 2;
|
|
1379
|
+
this.registerWidget(scr, 'edit_proxy', 12, px, '[изменить прокси]', () =>
|
|
1380
|
+
this.openPrompt('Прокси (--proxy)', this.opts.proxyUrl, (v) => {
|
|
1381
|
+
this.opts.proxyUrl = v || 'http://127.0.0.1:8080';
|
|
1382
|
+
})
|
|
1383
|
+
);
|
|
1384
|
+
|
|
1385
|
+
let ckx =
|
|
1386
|
+
this.registerWidget(
|
|
1387
|
+
scr,
|
|
1388
|
+
'flag_cookies',
|
|
1389
|
+
13,
|
|
1390
|
+
c1 + 2,
|
|
1391
|
+
`Брать cookies из браузера ${this.opts.cookiesBrowser}`,
|
|
1392
|
+
() => this._toggle('useCookiesBrowser'),
|
|
1393
|
+
{ checked: this.opts.useCookiesBrowser }
|
|
1394
|
+
) + 2;
|
|
1395
|
+
this.registerWidget(scr, 'cycle_browser', 13, ckx, '[сменить браузер]', () => {
|
|
1396
|
+
const idx = BROWSERS.indexOf(this.opts.cookiesBrowser);
|
|
1397
|
+
const nextB = BROWSERS[(idx + 1) % BROWSERS.length];
|
|
1398
|
+
this.opts.cookiesBrowser = nextB;
|
|
1399
|
+
this.opts.useCookiesBrowser = nextB !== 'none';
|
|
1400
|
+
this.statusBanner = `Cookies браузер: ${nextB}`;
|
|
1401
|
+
this.render();
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
scr.putStr(15, c1, 'ШАБЛОНЫ ИМЕНОВАНИЯ ФАЙЛОВ (-o):', ANSI.fgCyan + ANSI.bold);
|
|
1405
|
+
NAMING_PRESETS.forEach((p, idx) => {
|
|
1406
|
+
this.registerWidget(
|
|
1407
|
+
scr,
|
|
1408
|
+
`preset_${idx}`,
|
|
1409
|
+
16 + idx,
|
|
1410
|
+
c1 + 2,
|
|
1411
|
+
p.label,
|
|
1412
|
+
() => {
|
|
1413
|
+
this.opts.outputTemplate = p.tpl;
|
|
1414
|
+
this.opts.usePlaylistTemplate = true;
|
|
1415
|
+
this.statusBanner = `Шаблон: ${p.tpl}`;
|
|
1416
|
+
this.render();
|
|
1417
|
+
},
|
|
1418
|
+
{ radio: this.opts.outputTemplate === p.tpl, maxWidth: w - 8 }
|
|
1419
|
+
);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
scr.putStr(20, c1, 'ОКРУЖЕНИЕ:', ANSI.fgCyan + ANSI.bold);
|
|
1423
|
+
scr.putStr(
|
|
1424
|
+
21,
|
|
1425
|
+
c1 + 2,
|
|
1426
|
+
`Node.js : ${process.version} (${process.platform}/${process.arch})`.slice(0, w - 8),
|
|
1427
|
+
ANSI.fgGreen
|
|
1428
|
+
);
|
|
1429
|
+
scr.putStr(
|
|
1430
|
+
22,
|
|
1431
|
+
c1 + 2,
|
|
1432
|
+
`yt-dlp : ${this.manager.ytdlpPath || 'НЕ НАЙДЕН (Установите через winget/brew/apt)'}`.slice(0, w - 8),
|
|
1433
|
+
this.manager.ytdlpPath ? ANSI.fgGreen : ANSI.fgRed
|
|
1434
|
+
);
|
|
1435
|
+
scr.putStr(
|
|
1436
|
+
23,
|
|
1437
|
+
c1 + 2,
|
|
1438
|
+
`ffmpeg : ${this.manager.ffmpegPath || 'НЕ НАЙДЕН (Нужен для конвертации в mp3/flac)'}`.slice(0, w - 8),
|
|
1439
|
+
this.manager.ffmpegPath ? ANSI.fgGreen : ANSI.fgYellow
|
|
1440
|
+
);
|
|
1441
|
+
|
|
1442
|
+
if (8 + h - 2 >= 25) {
|
|
1443
|
+
this.registerWidget(
|
|
1444
|
+
scr,
|
|
1445
|
+
'btn_save_cfg',
|
|
1446
|
+
Math.min(25, 8 + h - 2),
|
|
1447
|
+
c1,
|
|
1448
|
+
'[ Сохранить текущие настройки по умолчанию ]',
|
|
1449
|
+
() => {
|
|
1450
|
+
try {
|
|
1451
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
1452
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.opts, null, 2), 'utf8');
|
|
1453
|
+
this.statusBanner = `Настройки сохранены`;
|
|
1454
|
+
} catch (err) {
|
|
1455
|
+
this.statusBanner = `Ошибка: ${err.message}`;
|
|
1456
|
+
}
|
|
1457
|
+
this.render();
|
|
1458
|
+
},
|
|
1459
|
+
{ isCta: true }
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// -------------------------------------------------------------------------
|
|
1465
|
+
// Tab 5: About Tab
|
|
1466
|
+
// -------------------------------------------------------------------------
|
|
1467
|
+
|
|
1468
|
+
_renderAboutTab(scr, x, w, rows) {
|
|
1469
|
+
const h = rows - 9;
|
|
1470
|
+
scr.drawBox(8, x, h, w, 'About EVA.md (Node.js Edition)');
|
|
1471
|
+
const lines = [
|
|
1472
|
+
`EVA.md ${APP_VERSION} — Консольный загрузчик музыки и видео`,
|
|
1473
|
+
'',
|
|
1474
|
+
'Поддерживаемые площадки (9 источников):',
|
|
1475
|
+
' 01. YouTube 04. SoundCloud 07. Audius',
|
|
1476
|
+
' 02. YouTube Music 05. Mixcloud 08. ВКонтакте (VK)',
|
|
1477
|
+
' 03. Bandcamp 06. Audiomack 09. Internet Archive',
|
|
1478
|
+
'',
|
|
1479
|
+
'Интегрированные флаги yt-dlp:',
|
|
1480
|
+
' • Аудио: -x, --audio-format (mp3/flac/m4a/wav), --audio-quality 0, --embed-metadata, --embed-thumbnail',
|
|
1481
|
+
' • Видео: -f "bv*+ba/b", --write-subs, --embed-subs, --write-thumbnail, --merge-output-format mp4',
|
|
1482
|
+
' • Файлы: -o "%(playlist_index)s. %(title)s.%(ext)s", --download-archive archive.txt',
|
|
1483
|
+
' • Плейлисты: --playlist-items 1-5, --playlist-reverse',
|
|
1484
|
+
' • Сеть (вкладка Settings): -4, --proxy http://127.0.0.1:8080, --cookies-from-browser chrome',
|
|
1485
|
+
];
|
|
1486
|
+
lines.forEach((line, i) => {
|
|
1487
|
+
if (10 + i < rows - 2) scr.putStr(10 + i, x + 3, line.slice(0, w - 6), ANSI.fgWhite);
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// -------------------------------------------------------------------------
|
|
1492
|
+
// Actions & Input Handling
|
|
1493
|
+
// -------------------------------------------------------------------------
|
|
1494
|
+
|
|
1495
|
+
_selectSource(sid) {
|
|
1496
|
+
this.opts.sourceId = sid;
|
|
1497
|
+
const s = SOURCES.find((item) => item.id === sid) || SOURCES[0];
|
|
1498
|
+
if (sid === 8 && !this.opts.useCookiesBrowser) {
|
|
1499
|
+
this.opts.useCookiesBrowser = true;
|
|
1500
|
+
this.statusBanner = `${s.name} (включен --cookies-from-browser)`;
|
|
1501
|
+
} else if (sid === 1 && this.opts.url.trim()) {
|
|
1502
|
+
const detected = detectSource(this.opts.url, sid);
|
|
1503
|
+
this.statusBanner = `URL (${detected.name})`;
|
|
1504
|
+
} else {
|
|
1505
|
+
this.statusBanner = `Источник: ${s.name}`;
|
|
1506
|
+
}
|
|
1507
|
+
this.render();
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
_setUrl(val) {
|
|
1511
|
+
this.opts.url = val;
|
|
1512
|
+
if (val) {
|
|
1513
|
+
const detected = detectSource(val, this.opts.sourceId);
|
|
1514
|
+
if (this.opts.sourceId !== 1) {
|
|
1515
|
+
this.opts.sourceId = detected.id;
|
|
1516
|
+
}
|
|
1517
|
+
this.statusBanner = `URL (${detected.name})`;
|
|
1518
|
+
}
|
|
1519
|
+
this.render();
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
_toggle(key) {
|
|
1523
|
+
this.opts[key] = !this.opts[key];
|
|
1524
|
+
this.render();
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
_actionAddToQueue() {
|
|
1528
|
+
if (!this.opts.url.trim()) {
|
|
1529
|
+
this.openPrompt('Сначала вставьте ссылку (URL)', this.opts.url, (val) => {
|
|
1530
|
+
this._setUrl(val);
|
|
1531
|
+
if (this.opts.url.trim()) {
|
|
1532
|
+
const it = this.manager.addTask(this.opts, true);
|
|
1533
|
+
this.selectedQueueIdx = this.manager.items.length - 1;
|
|
1534
|
+
this.statusBanner = `+ #${this.manager.items.length} (${it.sourceName})`;
|
|
1535
|
+
this.render();
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const item = this.manager.addTask(this.opts, true);
|
|
1541
|
+
this.selectedQueueIdx = this.manager.items.length - 1;
|
|
1542
|
+
this.statusBanner = `+ #${this.manager.items.length} (${item.sourceName})`;
|
|
1543
|
+
this.render();
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
_qAction(type) {
|
|
1547
|
+
const it = this.manager.items[this.selectedQueueIdx];
|
|
1548
|
+
if (!it) return;
|
|
1549
|
+
if (type === 'resume') this.manager.resumeItem(it);
|
|
1550
|
+
else if (type === 'pause') this.manager.pauseItem(it);
|
|
1551
|
+
else if (type === 'cancel') this.manager.cancelItem(it);
|
|
1552
|
+
else if (type === 'remove') this.manager.removeItem(this.selectedQueueIdx);
|
|
1553
|
+
this.render();
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
handleInput(data) {
|
|
1557
|
+
if (this.modal) {
|
|
1558
|
+
if (data === '\r' || data === '\n') {
|
|
1559
|
+
const cb = this.modal.onConfirm;
|
|
1560
|
+
const val = this.modal.value.trim();
|
|
1561
|
+
this.modal = null;
|
|
1562
|
+
cb(val);
|
|
1563
|
+
this.render();
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (data === '\x1b') {
|
|
1567
|
+
this.modal = null;
|
|
1568
|
+
this.render();
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
if (data === '\x15') {
|
|
1572
|
+
this.modal.value = '';
|
|
1573
|
+
this.modal.cursor = 0;
|
|
1574
|
+
this.render();
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (data === '\x7f' || data === '\b') {
|
|
1578
|
+
if (this.modal.value.length > 0) {
|
|
1579
|
+
this.modal.value = this.modal.value.slice(0, -1);
|
|
1580
|
+
}
|
|
1581
|
+
this.render();
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
if (!data.startsWith('\x1b')) {
|
|
1585
|
+
const clean = data.replace(/[\r\n\t]/g, '');
|
|
1586
|
+
this.modal.value += clean;
|
|
1587
|
+
this.render();
|
|
1588
|
+
}
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const mouseMatch = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(data);
|
|
1593
|
+
if (mouseMatch) {
|
|
1594
|
+
const btn = parseInt(mouseMatch[1], 10);
|
|
1595
|
+
const mx = parseInt(mouseMatch[2], 10) - 1;
|
|
1596
|
+
const my = parseInt(mouseMatch[3], 10) - 1;
|
|
1597
|
+
const isPress = mouseMatch[4] === 'M';
|
|
1598
|
+
if (isPress && btn === 0) {
|
|
1599
|
+
const hit = this.hitboxes.find((h) => h.y === my && mx >= h.x1 && mx <= h.x2);
|
|
1600
|
+
if (hit) {
|
|
1601
|
+
this.focusId = hit.id;
|
|
1602
|
+
hit.action();
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
if (data === '\x03' || data === 'q' || data === 'Q' || data === '\x1b') {
|
|
1609
|
+
this.quit();
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (['1', '2', '3', '4', '5'].includes(data)) {
|
|
1613
|
+
this.activeTab = parseInt(data, 10);
|
|
1614
|
+
this.render();
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (data === 'u' || data === 'U') {
|
|
1618
|
+
this.activeTab = 1;
|
|
1619
|
+
this.openPrompt('Вставьте ссылку на трек или плейлист', this.opts.url, (val) => this._setUrl(val));
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (data === 'a' || data === 'A') {
|
|
1623
|
+
this._actionAddToQueue();
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
if (data === 'r' || data === 'R') return this._qAction('resume');
|
|
1627
|
+
if (data === 'p' || data === 'P') return this._qAction('pause');
|
|
1628
|
+
if (data === 'c' || data === 'C') return this._qAction('cancel');
|
|
1629
|
+
if (data === 'd' || data === 'D') return this._qAction('remove');
|
|
1630
|
+
|
|
1631
|
+
if (data === '\t' || data === '\x1b[B' || data === '\x1b[C') {
|
|
1632
|
+
if (this.focusOrder.length > 0) {
|
|
1633
|
+
const idx = Math.max(0, this.focusOrder.indexOf(this.focusId));
|
|
1634
|
+
this.focusId = this.focusOrder[(idx + 1) % this.focusOrder.length];
|
|
1635
|
+
this.render();
|
|
1636
|
+
}
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
if (data === '\x1b[Z' || data === '\x1b[A' || data === '\x1b[D') {
|
|
1640
|
+
if (this.focusOrder.length > 0) {
|
|
1641
|
+
const idx = Math.max(0, this.focusOrder.indexOf(this.focusId));
|
|
1642
|
+
this.focusId = this.focusOrder[(idx - 1 + this.focusOrder.length) % this.focusOrder.length];
|
|
1643
|
+
this.render();
|
|
1644
|
+
}
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (data === ' ' || data === '\r' || data === '\n') {
|
|
1648
|
+
const hit = this.hitboxes.find((h) => h.id === this.focusId);
|
|
1649
|
+
if (hit) hit.action();
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// ---------------------------------------------------------------------------
|
|
1655
|
+
// CLI Entrypoint & Exports for Testing
|
|
1656
|
+
// ---------------------------------------------------------------------------
|
|
1657
|
+
|
|
1658
|
+
function updatePackage() {
|
|
1659
|
+
console.log('Обновление EVA.md...');
|
|
1660
|
+
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' });
|
|
1663
|
+
|
|
1664
|
+
if (result.error) {
|
|
1665
|
+
console.error(`Не удалось запустить npm: ${result.error.message}`);
|
|
1666
|
+
process.exitCode = 1;
|
|
1667
|
+
} else if (result.status === 0) {
|
|
1668
|
+
console.log('EVA.md обновлена. Запустите команду eva снова.');
|
|
1669
|
+
} else {
|
|
1670
|
+
process.exitCode = result.status || 1;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function main() {
|
|
1675
|
+
const argv = process.argv.slice(2);
|
|
1676
|
+
if (argv[0] === '--update') {
|
|
1677
|
+
if (argv.length !== 1) {
|
|
1678
|
+
console.error('Использование: eva --update');
|
|
1679
|
+
process.exitCode = 1;
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
updatePackage();
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const isDemo = argv.includes('--demo');
|
|
1687
|
+
const isPrintCmd = argv.includes('--print-cmd');
|
|
1688
|
+
const urlArg = argv.find((a) => !a.startsWith('-')) || '';
|
|
1689
|
+
|
|
1690
|
+
const opts = loadSavedOptions();
|
|
1691
|
+
if (urlArg) opts.url = urlArg;
|
|
1692
|
+
|
|
1693
|
+
if (isPrintCmd) {
|
|
1694
|
+
console.log(buildCommandPreview(opts));
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const manager = new DownloadManager(isDemo);
|
|
1699
|
+
|
|
1700
|
+
if (isDemo) {
|
|
1701
|
+
if (!opts.url) opts.url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
|
1702
|
+
const samples = [
|
|
1703
|
+
{ title: 'Валентин Стрыкало — Наше лето', src: 'SoundCloud', fmt: 'mp3', sz: '4.20 MB', prog: 68, st: 'Downloading' },
|
|
1704
|
+
{ title: 'Lo-fi beats to relax/study to', src: 'YouTube Music', fmt: 'mp3', sz: '12.60 MB', prog: 25, st: 'Queued' },
|
|
1705
|
+
{ title: 'The Weeknd - Blinding Lights', src: 'SoundCloud', fmt: 'mp3', sz: '8.70 MB', prog: 0, st: 'Queued' },
|
|
1706
|
+
{ title: 'Imagine Dragons - Believer', src: 'Bandcamp', fmt: 'flac', sz: '24.30 MB', prog: 100, st: 'Done' },
|
|
1707
|
+
];
|
|
1708
|
+
samples.forEach((s, i) => {
|
|
1709
|
+
manager.items.push({
|
|
1710
|
+
id: i + 1,
|
|
1711
|
+
url: opts.url,
|
|
1712
|
+
title: s.title,
|
|
1713
|
+
sourceName: s.src,
|
|
1714
|
+
fmt: s.fmt,
|
|
1715
|
+
outDir: opts.outputDir,
|
|
1716
|
+
destFile: '',
|
|
1717
|
+
sizeStr: s.sz,
|
|
1718
|
+
progress: s.prog,
|
|
1719
|
+
speedStr: '',
|
|
1720
|
+
etaStr: '',
|
|
1721
|
+
status: s.st,
|
|
1722
|
+
cmdArgs: buildYtdlpArgs(opts, opts.url),
|
|
1723
|
+
errorMsg: '',
|
|
1724
|
+
proc: null,
|
|
1725
|
+
simTimer: null,
|
|
1726
|
+
logLines: [`[download] ${s.prog}% of ${s.sz}`],
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
manager.nextId = samples.length + 1;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
const app = new EvaNodeTUI(opts, manager);
|
|
1733
|
+
app.start();
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (require.main === module) {
|
|
1737
|
+
main();
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
module.exports = {
|
|
1741
|
+
createDefaultOptions,
|
|
1742
|
+
buildYtdlpArgs,
|
|
1743
|
+
buildCommandPreview,
|
|
1744
|
+
detectSource,
|
|
1745
|
+
DownloadManager,
|
|
1746
|
+
formatBytesToMB,
|
|
1747
|
+
wrapTextByWords,
|
|
1748
|
+
};
|