ssdl 1.0.0
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/CHANGELOG.md +28 -0
- package/CODE_OF_CONDUCT.md +46 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +201 -0
- package/README.md +105 -0
- package/bin/cli.js +69 -0
- package/package.json +34 -0
- package/src/app.js +327 -0
- package/src/constants.js +91 -0
- package/src/screens/Complete.js +73 -0
- package/src/screens/Downloading.js +176 -0
- package/src/screens/Search.js +100 -0
- package/src/screens/TrackList.js +85 -0
- package/src/screens/URLInput.js +41 -0
- package/src/screens/Welcome.js +62 -0
- package/src/services/downloader.js +93 -0
- package/src/services/metadata.js +48 -0
- package/src/services/spotify.js +208 -0
- package/src/services/youtube.js +151 -0
- package/src/tui/input.js +247 -0
- package/src/tui/renderer.js +208 -0
- package/src/utils/config.js +64 -0
- package/src/utils/deps.js +51 -0
- package/src/utils/format.js +47 -0
package/src/app.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { createInputHandler } from './tui/input.js';
|
|
2
|
+
import { render, c, cursor, screen } from './tui/renderer.js';
|
|
3
|
+
import { formatDuration } from './utils/format.js';
|
|
4
|
+
import { loadConfig, saveConfig, hasCredentials } from './utils/config.js';
|
|
5
|
+
import { textInput } from './tui/input.js';
|
|
6
|
+
import * as spotify from './services/spotify.js';
|
|
7
|
+
import { showWelcome } from './screens/Welcome.js';
|
|
8
|
+
import { showURLInput } from './screens/URLInput.js';
|
|
9
|
+
import { showSearch } from './screens/Search.js';
|
|
10
|
+
import { showTrackList } from './screens/TrackList.js';
|
|
11
|
+
import { showDownloading } from './screens/Downloading.js';
|
|
12
|
+
import { showComplete } from './screens/Complete.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Main application — handles screen routing and state management
|
|
16
|
+
*/
|
|
17
|
+
export async function startApp(initialUrl = null) {
|
|
18
|
+
const input = createInputHandler();
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
|
|
21
|
+
// Ensure clean exit
|
|
22
|
+
process.on('exit', () => {
|
|
23
|
+
cursor.show();
|
|
24
|
+
if (process.stdin.isTTY) {
|
|
25
|
+
try { process.stdin.setRawMode(false); } catch { }
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
cursor.hide();
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// ─── Step 1: Check / prompt for Spotify credentials ────
|
|
33
|
+
if (!hasCredentials(config)) {
|
|
34
|
+
await promptCredentials(input, config);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Step 2: Authenticate with Spotify ─────────────────
|
|
38
|
+
if (!spotify.isAuthenticated()) {
|
|
39
|
+
screen.clear();
|
|
40
|
+
render('\n' + c.cyan(' Authenticating with Spotify...'));
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await spotify.authenticate(config.spotifyClientId, config.spotifyClientSecret);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
render('\n' + c.red(` ✗ Authentication failed: ${err.message}\n\n`) +
|
|
46
|
+
c.dim(' Check your Client ID and Secret.\n') +
|
|
47
|
+
c.dim(' Run ssdl again to re-enter credentials.\n'));
|
|
48
|
+
|
|
49
|
+
// Clear saved credentials so they're prompted again
|
|
50
|
+
config.spotifyClientId = '';
|
|
51
|
+
config.spotifyClientSecret = '';
|
|
52
|
+
saveConfig(config);
|
|
53
|
+
|
|
54
|
+
cursor.show();
|
|
55
|
+
input.destroy();
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Step 3: If a URL was passed as argument, skip to it ─
|
|
61
|
+
if (initialUrl) {
|
|
62
|
+
await handleUrl(initialUrl, config, input);
|
|
63
|
+
cursor.show();
|
|
64
|
+
input.destroy();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Step 4: Main loop — Welcome screen ────────────────
|
|
69
|
+
let running = true;
|
|
70
|
+
while (running) {
|
|
71
|
+
const action = await showWelcome(input);
|
|
72
|
+
|
|
73
|
+
switch (action) {
|
|
74
|
+
case 'url': {
|
|
75
|
+
const url = await showURLInput(input);
|
|
76
|
+
if (url) {
|
|
77
|
+
await handleUrl(url, config, input);
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'search': {
|
|
83
|
+
const track = await showSearch(input);
|
|
84
|
+
if (track) {
|
|
85
|
+
await handleTracks([track], 'track', config, input);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'settings': {
|
|
91
|
+
await showSettings(input, config);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case 'exit':
|
|
96
|
+
running = false;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
cursor.show();
|
|
102
|
+
input.destroy();
|
|
103
|
+
screen.clear();
|
|
104
|
+
console.log(c.dim(' Goodbye!\n'));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Handle a Spotify URL — parse, fetch metadata, show tracks, download
|
|
110
|
+
*/
|
|
111
|
+
async function handleUrl(url, config, input) {
|
|
112
|
+
const parsed = spotify.parseSpotifyUrl(url);
|
|
113
|
+
|
|
114
|
+
if (!parsed) {
|
|
115
|
+
render(
|
|
116
|
+
'\n' +
|
|
117
|
+
c.red(' Invalid Spotify URL\n\n') +
|
|
118
|
+
c.dim(' Supported formats:\n') +
|
|
119
|
+
c.dim(' open.spotify.com/track/...\n') +
|
|
120
|
+
c.dim(' open.spotify.com/album/...\n') +
|
|
121
|
+
c.dim(' open.spotify.com/playlist/...\n\n') +
|
|
122
|
+
c.dim(' Press Enter to go back')
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
input.clearHandlers();
|
|
127
|
+
input.on('return', () => { input.clearHandlers(); resolve(); });
|
|
128
|
+
input.on('escape', () => { input.clearHandlers(); resolve(); });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Fetch metadata
|
|
133
|
+
const typeLabel = parsed.type.charAt(0).toUpperCase() + parsed.type.slice(1);
|
|
134
|
+
render('\n' + c.cyan(` Fetching ${typeLabel.toLowerCase()} info...`));
|
|
135
|
+
|
|
136
|
+
let data;
|
|
137
|
+
try {
|
|
138
|
+
switch (parsed.type) {
|
|
139
|
+
case 'track':
|
|
140
|
+
data = await spotify.getTrack(parsed.id);
|
|
141
|
+
break;
|
|
142
|
+
case 'playlist':
|
|
143
|
+
data = await spotify.getPlaylist(parsed.id);
|
|
144
|
+
break;
|
|
145
|
+
case 'album':
|
|
146
|
+
data = await spotify.getAlbum(parsed.id);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
render(
|
|
151
|
+
'\n' +
|
|
152
|
+
c.red(` Failed to fetch: ${err.message}\n\n`) +
|
|
153
|
+
c.dim(' Check the URL and try again\n\n') +
|
|
154
|
+
c.dim(' Press Enter to go back')
|
|
155
|
+
);
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
input.clearHandlers();
|
|
158
|
+
input.on('return', () => { input.clearHandlers(); resolve(); });
|
|
159
|
+
input.on('escape', () => { input.clearHandlers(); resolve(); });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// For single track, show preview first
|
|
164
|
+
if (parsed.type === 'track') {
|
|
165
|
+
const confirmed = await showTrackPreview(data, input);
|
|
166
|
+
if (confirmed) {
|
|
167
|
+
await handleTracks([data], 'track', config, input);
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
await handleTracks(data, parsed.type, config, input);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Show a preview of a single track before downloading
|
|
176
|
+
*/
|
|
177
|
+
async function showTrackPreview(track, input) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let content = '\n';
|
|
180
|
+
content += c.brightCyan(' Track found\n');
|
|
181
|
+
content += c.dim(' ─────────────────────────────────────────────────────\n\n');
|
|
182
|
+
content += ` ${c.brightWhite(track.title)}\n`;
|
|
183
|
+
content += ` ${c.cyan(track.artist)}\n`;
|
|
184
|
+
content += ` ${c.dim(track.album)}`;
|
|
185
|
+
if (track.releaseDate) content += c.dim(` (${track.releaseDate.slice(0, 4)})`);
|
|
186
|
+
content += '\n';
|
|
187
|
+
content += ` ${c.dim(formatDuration(track.duration))}\n`;
|
|
188
|
+
content += '\n';
|
|
189
|
+
content += c.dim(' ─────────────────────────────────────────────────────\n');
|
|
190
|
+
content += '\n';
|
|
191
|
+
content += c.dim(' Enter download | Esc back');
|
|
192
|
+
|
|
193
|
+
render(content);
|
|
194
|
+
|
|
195
|
+
input.clearHandlers();
|
|
196
|
+
input.on('return', () => { input.clearHandlers(); resolve(true); });
|
|
197
|
+
input.on('escape', () => { input.clearHandlers(); resolve(false); });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Handle track selection and download flow
|
|
203
|
+
*/
|
|
204
|
+
async function handleTracks(data, type, config, input) {
|
|
205
|
+
// Show track list for selection (for playlists/albums)
|
|
206
|
+
const selectedTracks = await showTrackList(data, type, input);
|
|
207
|
+
if (!selectedTracks || selectedTracks.length === 0) return;
|
|
208
|
+
|
|
209
|
+
// Download
|
|
210
|
+
const startTime = Date.now();
|
|
211
|
+
const results = await showDownloading(selectedTracks, config.downloadDir);
|
|
212
|
+
|
|
213
|
+
// Show completion
|
|
214
|
+
const action = await showComplete(results, config.downloadDir, startTime, input);
|
|
215
|
+
if (action === 'quit') {
|
|
216
|
+
cursor.show();
|
|
217
|
+
input.destroy();
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Prompt for Spotify credentials on first run
|
|
224
|
+
*/
|
|
225
|
+
async function promptCredentials(input, config) {
|
|
226
|
+
render(
|
|
227
|
+
'\n' +
|
|
228
|
+
c.brightCyan(' Welcome to ssdl!\n\n') +
|
|
229
|
+
c.white(' To get started, you need Spotify API credentials.\n\n') +
|
|
230
|
+
c.dim(' 1. Go to ') + c.cyan('https://developer.spotify.com/dashboard') + '\n' +
|
|
231
|
+
c.dim(' 2. Create a new app\n') +
|
|
232
|
+
c.dim(' 3. Copy your Client ID and Client Secret\n\n')
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Get Client ID
|
|
236
|
+
const clientId = await textInput(input, (text) => {
|
|
237
|
+
let content = '\n';
|
|
238
|
+
content += c.brightCyan(' Spotify Setup\n\n');
|
|
239
|
+
content += c.dim(' Create an app at: ') + c.cyan('https://developer.spotify.com/dashboard\n\n');
|
|
240
|
+
content += c.white(' Enter your Client ID:\n');
|
|
241
|
+
content += ` > ${text}${c.dim('█')}\n`;
|
|
242
|
+
render(content);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (!clientId) {
|
|
246
|
+
console.log(c.red('\n Setup cancelled.\n'));
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Get Client Secret
|
|
251
|
+
const clientSecret = await textInput(input, (text) => {
|
|
252
|
+
let content = '\n';
|
|
253
|
+
content += c.brightCyan(' Spotify Setup\n\n');
|
|
254
|
+
content += c.green(` ✓ Client ID: ${clientId.slice(0, 8)}...\n\n`);
|
|
255
|
+
content += c.white(' Enter your Client Secret:\n');
|
|
256
|
+
content += ` > ${text}${c.dim('█')}\n`;
|
|
257
|
+
render(content);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (!clientSecret) {
|
|
261
|
+
console.log(c.red('\n Setup cancelled.\n'));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Save credentials
|
|
266
|
+
config.spotifyClientId = clientId.trim();
|
|
267
|
+
config.spotifyClientSecret = clientSecret.trim();
|
|
268
|
+
saveConfig(config);
|
|
269
|
+
|
|
270
|
+
render('\n' + c.green(' ✓ Credentials saved to ~/.ssdl/config.json\n'));
|
|
271
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Settings screen
|
|
276
|
+
*/
|
|
277
|
+
async function showSettings(input, config) {
|
|
278
|
+
const items = [
|
|
279
|
+
{ label: `Download directory: ${c.cyan(config.downloadDir)}`, value: 'downloadDir' },
|
|
280
|
+
{ label: `Reset Spotify credentials`, value: 'resetCreds' },
|
|
281
|
+
{ label: `Back`, value: 'back' },
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const { menuSelect } = await import('./tui/input.js');
|
|
285
|
+
|
|
286
|
+
const selectedIdx = await menuSelect(items, input, (items, selected) => {
|
|
287
|
+
let content = '\n';
|
|
288
|
+
content += c.brightCyan(' Settings\n\n');
|
|
289
|
+
|
|
290
|
+
items.forEach((item, i) => {
|
|
291
|
+
const prefix = i === selected ? c.brightCyan(' ❯ ') : ' ';
|
|
292
|
+
const label = i === selected ? c.brightWhite(item.label) : item.label;
|
|
293
|
+
content += `${prefix}${label}\n`;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
content += '\n' + c.dim(' ↑/↓ navigate │ Enter select │ Esc back');
|
|
297
|
+
render(content);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (selectedIdx === -1 || items[selectedIdx].value === 'back') return;
|
|
301
|
+
|
|
302
|
+
if (items[selectedIdx].value === 'downloadDir') {
|
|
303
|
+
const newDir = await textInput(input, (text) => {
|
|
304
|
+
let content = '\n';
|
|
305
|
+
content += c.brightCyan(' Set download directory:\n\n');
|
|
306
|
+
content += ` > ${text}${c.dim('█')}\n\n`;
|
|
307
|
+
content += c.dim(` Current: ${config.downloadDir}\n`);
|
|
308
|
+
content += c.dim(' Press Enter to save, Esc to cancel\n');
|
|
309
|
+
render(content);
|
|
310
|
+
}, config.downloadDir);
|
|
311
|
+
|
|
312
|
+
if (newDir) {
|
|
313
|
+
config.downloadDir = newDir.trim();
|
|
314
|
+
saveConfig(config);
|
|
315
|
+
render('\n' + c.green(' ✓ Download directory updated!\n'));
|
|
316
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (items[selectedIdx].value === 'resetCreds') {
|
|
321
|
+
config.spotifyClientId = '';
|
|
322
|
+
config.spotifyClientSecret = '';
|
|
323
|
+
saveConfig(config);
|
|
324
|
+
render('\n' + c.green(' ✓ Credentials cleared. You\'ll be prompted on next run.\n'));
|
|
325
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// ─── App Info ────────────────────────────────────────────
|
|
2
|
+
export const APP_NAME = 'ssdl';
|
|
3
|
+
export const APP_VERSION = '1.0.0';
|
|
4
|
+
export const APP_DESCRIPTION = 'Spotify Song Downloader';
|
|
5
|
+
|
|
6
|
+
// ─── Paths ───────────────────────────────────────────────
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
export const CONFIG_DIR = join(homedir(), '.ssdl');
|
|
11
|
+
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
12
|
+
export const DEFAULT_DOWNLOAD_DIR = join(homedir(), 'Music', 'ssdl');
|
|
13
|
+
|
|
14
|
+
// ─── Spotify ─────────────────────────────────────────────
|
|
15
|
+
export const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token';
|
|
16
|
+
export const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
|
|
17
|
+
|
|
18
|
+
// ─── ANSI Colors ─────────────────────────────────────────
|
|
19
|
+
export const COLORS = {
|
|
20
|
+
reset: '\x1b[0m',
|
|
21
|
+
bold: '\x1b[1m',
|
|
22
|
+
dim: '\x1b[2m',
|
|
23
|
+
italic: '\x1b[3m',
|
|
24
|
+
underline: '\x1b[4m',
|
|
25
|
+
|
|
26
|
+
// Foreground
|
|
27
|
+
black: '\x1b[30m',
|
|
28
|
+
red: '\x1b[31m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
yellow: '\x1b[33m',
|
|
31
|
+
blue: '\x1b[34m',
|
|
32
|
+
magenta: '\x1b[35m',
|
|
33
|
+
cyan: '\x1b[36m',
|
|
34
|
+
white: '\x1b[37m',
|
|
35
|
+
|
|
36
|
+
// Bright foreground
|
|
37
|
+
brightBlack: '\x1b[90m',
|
|
38
|
+
brightRed: '\x1b[91m',
|
|
39
|
+
brightGreen: '\x1b[92m',
|
|
40
|
+
brightYellow: '\x1b[93m',
|
|
41
|
+
brightBlue: '\x1b[94m',
|
|
42
|
+
brightMagenta: '\x1b[95m',
|
|
43
|
+
brightCyan: '\x1b[96m',
|
|
44
|
+
brightWhite: '\x1b[97m',
|
|
45
|
+
|
|
46
|
+
// Background
|
|
47
|
+
bgBlack: '\x1b[40m',
|
|
48
|
+
bgRed: '\x1b[41m',
|
|
49
|
+
bgGreen: '\x1b[42m',
|
|
50
|
+
bgYellow: '\x1b[43m',
|
|
51
|
+
bgBlue: '\x1b[44m',
|
|
52
|
+
bgMagenta: '\x1b[45m',
|
|
53
|
+
bgCyan: '\x1b[46m',
|
|
54
|
+
bgWhite: '\x1b[47m',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ─── Box Drawing Characters ──────────────────────────────
|
|
58
|
+
export const BOX = {
|
|
59
|
+
topLeft: '╭',
|
|
60
|
+
topRight: '╮',
|
|
61
|
+
bottomLeft: '╰',
|
|
62
|
+
bottomRight: '╯',
|
|
63
|
+
horizontal: '─',
|
|
64
|
+
vertical: '│',
|
|
65
|
+
teeRight: '├',
|
|
66
|
+
teeLeft: '┤',
|
|
67
|
+
teeDown: '┬',
|
|
68
|
+
teeUp: '┴',
|
|
69
|
+
cross: '┼',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ─── Symbols ─────────────────────────────────────────────
|
|
73
|
+
export const SYMBOLS = {
|
|
74
|
+
check: '✅',
|
|
75
|
+
cross: '❌',
|
|
76
|
+
spinner: '🔄',
|
|
77
|
+
queue: '⏳',
|
|
78
|
+
music: '🎵',
|
|
79
|
+
folder: '📁',
|
|
80
|
+
link: '🔗',
|
|
81
|
+
search: '🔍',
|
|
82
|
+
download: '⬇️',
|
|
83
|
+
settings: '⚙️',
|
|
84
|
+
clipboard: '📋',
|
|
85
|
+
arrow: '❯',
|
|
86
|
+
dot: '●',
|
|
87
|
+
block: '█',
|
|
88
|
+
blockLight: '░',
|
|
89
|
+
blockMed: '▒',
|
|
90
|
+
blockFull: '████',
|
|
91
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { render, c } from '../tui/renderer.js';
|
|
2
|
+
import { formatBytes, formatTime, truncate } from '../utils/format.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Show the completion screen with download summary
|
|
6
|
+
* @param {object[]} results - Array of download results
|
|
7
|
+
* @param {string} outputDir - Download directory
|
|
8
|
+
* @param {number} startTime - Timestamp when downloads started
|
|
9
|
+
* @returns {Promise<string>} 'back' or 'quit'
|
|
10
|
+
*/
|
|
11
|
+
export async function showComplete(results, outputDir, startTime, input) {
|
|
12
|
+
const totalTime = (Date.now() - startTime) / 1000;
|
|
13
|
+
const successful = results.filter(r => r.success);
|
|
14
|
+
const failed = results.filter(r => !r.success);
|
|
15
|
+
const totalSize = successful.reduce((sum, r) => sum + (r.fileSize || 0), 0);
|
|
16
|
+
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let content = '\n';
|
|
19
|
+
|
|
20
|
+
// Header
|
|
21
|
+
if (failed.length === 0) {
|
|
22
|
+
content += c.brightGreen(' All downloads complete!\n');
|
|
23
|
+
} else if (successful.length > 0) {
|
|
24
|
+
content += c.yellow(` Downloads finished with ${failed.length} error${failed.length > 1 ? 's' : ''}\n`);
|
|
25
|
+
} else {
|
|
26
|
+
content += c.red(' All downloads failed\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
content += c.dim(' ─────────────────────────────────────────────────────\n\n');
|
|
30
|
+
|
|
31
|
+
// Successful downloads
|
|
32
|
+
if (successful.length > 0) {
|
|
33
|
+
content += c.dim(' Saved to: ') + c.cyan(outputDir + '/') + '\n\n';
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < successful.length; i++) {
|
|
36
|
+
const r = successful[i];
|
|
37
|
+
const num = c.dim(String(i + 1).padStart(2) + '.');
|
|
38
|
+
const name = truncate(`${r.track.title} — ${r.track.artist}`, 42);
|
|
39
|
+
const size = r.fileSize ? c.dim(formatBytes(r.fileSize)) : '';
|
|
40
|
+
content += ` ${c.green('+')} ${num} ${name} ${size}\n`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Failed downloads
|
|
45
|
+
if (failed.length > 0) {
|
|
46
|
+
content += '\n';
|
|
47
|
+
content += c.red(` Failed:\n`);
|
|
48
|
+
for (const r of failed) {
|
|
49
|
+
const name = truncate(`${r.track.title} — ${r.track.artist}`, 42);
|
|
50
|
+
content += ` ${c.red('x')} ${name}\n`;
|
|
51
|
+
content += c.dim(` ${r.error}\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Summary bar
|
|
56
|
+
content += '\n';
|
|
57
|
+
content += c.dim(' ─────────────────────────────────────────────────────\n');
|
|
58
|
+
const parts = [];
|
|
59
|
+
parts.push(`${successful.length}/${results.length} downloaded`);
|
|
60
|
+
if (totalSize > 0) parts.push(formatBytes(totalSize));
|
|
61
|
+
parts.push(formatTime(totalTime));
|
|
62
|
+
content += ` ${c.brightCyan(parts.join(' | '))}\n`;
|
|
63
|
+
|
|
64
|
+
content += '\n' + c.dim(' Enter go back | Q quit');
|
|
65
|
+
|
|
66
|
+
render(content);
|
|
67
|
+
|
|
68
|
+
input.clearHandlers();
|
|
69
|
+
input.on('return', () => { input.clearHandlers(); resolve('back'); });
|
|
70
|
+
input.on('q', () => { input.clearHandlers(); resolve('quit'); });
|
|
71
|
+
input.on('Q', () => { input.clearHandlers(); resolve('quit'); });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { render, progressBar, c } from '../tui/renderer.js';
|
|
2
|
+
import { searchTrack } from '../services/youtube.js';
|
|
3
|
+
import { downloadTrack } from '../services/downloader.js';
|
|
4
|
+
import { embedMetadata } from '../services/metadata.js';
|
|
5
|
+
import { truncate, formatBytes } from '../utils/format.js';
|
|
6
|
+
import { statSync } from 'fs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Show the download progress screen
|
|
10
|
+
* Downloads all selected tracks sequentially
|
|
11
|
+
* @param {object[]} tracks - Array of track metadata
|
|
12
|
+
* @param {string} outputDir - Download directory
|
|
13
|
+
* @returns {Promise<object[]>} Array of download results
|
|
14
|
+
*/
|
|
15
|
+
export async function showDownloading(tracks, outputDir) {
|
|
16
|
+
const results = [];
|
|
17
|
+
const startTime = Date.now();
|
|
18
|
+
|
|
19
|
+
// Status for each track
|
|
20
|
+
const statuses = tracks.map(() => ({
|
|
21
|
+
status: 'queued', percent: 0, message: '', filePath: '', fileSize: 0,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
function drawProgress() {
|
|
25
|
+
let content = '\n';
|
|
26
|
+
const done = statuses.filter(s => s.status === 'done').length;
|
|
27
|
+
const errors = statuses.filter(s => s.status === 'error').length;
|
|
28
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
29
|
+
|
|
30
|
+
// Header with overall progress
|
|
31
|
+
content += c.brightCyan(` Downloading`) + c.dim(` ${done}/${tracks.length}`) + '\n';
|
|
32
|
+
content += c.dim(` ─────────────────────────────────────────────────────\n`);
|
|
33
|
+
|
|
34
|
+
// Calculate visible window
|
|
35
|
+
const maxVisible = Math.min(tracks.length, Math.max(8, process.stdout.rows - 10));
|
|
36
|
+
|
|
37
|
+
// Find the current active track to center view on it
|
|
38
|
+
let activeIdx = statuses.findIndex(s =>
|
|
39
|
+
s.status === 'searching' || s.status === 'downloading' || s.status === 'metadata'
|
|
40
|
+
);
|
|
41
|
+
if (activeIdx === -1) activeIdx = done + errors;
|
|
42
|
+
|
|
43
|
+
let startIdx = Math.max(0, activeIdx - Math.floor(maxVisible / 2));
|
|
44
|
+
let endIdx = Math.min(tracks.length, startIdx + maxVisible);
|
|
45
|
+
if (endIdx - startIdx < maxVisible) startIdx = Math.max(0, endIdx - maxVisible);
|
|
46
|
+
|
|
47
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
48
|
+
const track = tracks[i];
|
|
49
|
+
const s = statuses[i];
|
|
50
|
+
const label = truncate(`${track.title} — ${track.artist}`, 36);
|
|
51
|
+
const num = c.dim(String(i + 1).padStart(2) + '.');
|
|
52
|
+
|
|
53
|
+
let icon, statusText;
|
|
54
|
+
|
|
55
|
+
switch (s.status) {
|
|
56
|
+
case 'queued':
|
|
57
|
+
icon = c.dim('·');
|
|
58
|
+
statusText = c.dim('waiting');
|
|
59
|
+
content += ` ${icon} ${num} ${c.dim(label)}\n`;
|
|
60
|
+
break;
|
|
61
|
+
case 'searching':
|
|
62
|
+
icon = c.cyan('~');
|
|
63
|
+
statusText = c.cyan('finding match...');
|
|
64
|
+
content += ` ${icon} ${num} ${label} ${statusText}\n`;
|
|
65
|
+
break;
|
|
66
|
+
case 'downloading': {
|
|
67
|
+
icon = c.yellow('>');
|
|
68
|
+
const bar = progressBar(s.percent, 12);
|
|
69
|
+
content += ` ${icon} ${num} ${label}\n`;
|
|
70
|
+
content += ` ${bar} ${c.dim(s.message || '')}\n`;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case 'metadata':
|
|
74
|
+
icon = c.cyan('>');
|
|
75
|
+
statusText = c.cyan('tagging...');
|
|
76
|
+
content += ` ${icon} ${num} ${c.cyan(label)} ${statusText}\n`;
|
|
77
|
+
break;
|
|
78
|
+
case 'done': {
|
|
79
|
+
icon = c.green('+');
|
|
80
|
+
const size = s.fileSize > 0 ? c.dim(` ${formatBytes(s.fileSize)}`) : '';
|
|
81
|
+
content += ` ${icon} ${num} ${c.green(label)}${size}\n`;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case 'error':
|
|
85
|
+
icon = c.red('x');
|
|
86
|
+
content += ` ${icon} ${num} ${c.red(label)} ${c.dim(s.message)}\n`;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Scroll indicator
|
|
92
|
+
if (tracks.length > maxVisible) {
|
|
93
|
+
content += c.dim(`\n ... ${startIdx + 1}-${endIdx} of ${tracks.length}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Footer status bar
|
|
97
|
+
content += '\n';
|
|
98
|
+
const mins = Math.floor(elapsed / 60);
|
|
99
|
+
const secs = elapsed % 60;
|
|
100
|
+
const timeStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
101
|
+
content += c.dim(` Elapsed: ${timeStr}`);
|
|
102
|
+
if (errors > 0) content += c.red(` | ${errors} failed`);
|
|
103
|
+
|
|
104
|
+
render(content);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Download each track sequentially
|
|
108
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
109
|
+
const track = tracks[i];
|
|
110
|
+
|
|
111
|
+
// Step 1: Find on YouTube
|
|
112
|
+
statuses[i].status = 'searching';
|
|
113
|
+
drawProgress();
|
|
114
|
+
|
|
115
|
+
let ytResult;
|
|
116
|
+
try {
|
|
117
|
+
ytResult = await searchTrack(track.title, track.artist, track.duration);
|
|
118
|
+
if (!ytResult) throw new Error('No match found');
|
|
119
|
+
} catch (err) {
|
|
120
|
+
statuses[i].status = 'error';
|
|
121
|
+
statuses[i].message = err.message;
|
|
122
|
+
results.push({ track, success: false, error: err.message });
|
|
123
|
+
drawProgress();
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Step 2: Download from YouTube
|
|
128
|
+
statuses[i].status = 'downloading';
|
|
129
|
+
drawProgress();
|
|
130
|
+
|
|
131
|
+
let filePath;
|
|
132
|
+
try {
|
|
133
|
+
filePath = await downloadTrack(ytResult.url, track, outputDir, (percent, msg) => {
|
|
134
|
+
statuses[i].percent = percent;
|
|
135
|
+
statuses[i].message = msg;
|
|
136
|
+
drawProgress();
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
statuses[i].status = 'error';
|
|
140
|
+
statuses[i].message = err.message;
|
|
141
|
+
results.push({ track, success: false, error: err.message });
|
|
142
|
+
drawProgress();
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Step 3: Embed metadata
|
|
147
|
+
statuses[i].status = 'metadata';
|
|
148
|
+
drawProgress();
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await embedMetadata(filePath, track);
|
|
152
|
+
} catch {
|
|
153
|
+
// Metadata failure is non-critical
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Done
|
|
157
|
+
statuses[i].status = 'done';
|
|
158
|
+
statuses[i].filePath = filePath;
|
|
159
|
+
try {
|
|
160
|
+
statuses[i].fileSize = statSync(filePath).size;
|
|
161
|
+
} catch {
|
|
162
|
+
statuses[i].fileSize = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
results.push({
|
|
166
|
+
track,
|
|
167
|
+
success: true,
|
|
168
|
+
filePath,
|
|
169
|
+
fileSize: statuses[i].fileSize,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
drawProgress();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return results;
|
|
176
|
+
}
|