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/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
+ }
@@ -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
+ }