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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube Music search — finds the best matching video for a track
|
|
3
|
+
* Uses YouTube Music's public search to avoid needing an API key
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const YT_MUSIC_URL = 'https://music.youtube.com';
|
|
7
|
+
const YT_SEARCH_URL = 'https://www.youtube.com/results';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Search for a track on YouTube and return the best match
|
|
11
|
+
*/
|
|
12
|
+
export async function searchTrack(title, artist, durationMs) {
|
|
13
|
+
const query = `${title} ${artist}`;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Try YouTube search page scrape
|
|
17
|
+
const result = await searchYouTube(query, durationMs);
|
|
18
|
+
if (result) return result;
|
|
19
|
+
} catch {
|
|
20
|
+
// Fallback: try a simpler search
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Fallback: use a basic search approach
|
|
24
|
+
return searchYouTubeFallback(query, durationMs);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Search YouTube by scraping search results page
|
|
29
|
+
*/
|
|
30
|
+
async function searchYouTube(query, durationMs) {
|
|
31
|
+
const encoded = encodeURIComponent(query);
|
|
32
|
+
const url = `${YT_SEARCH_URL}?search_query=${encoded}`;
|
|
33
|
+
|
|
34
|
+
const res = await fetch(url, {
|
|
35
|
+
headers: {
|
|
36
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
37
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!res.ok) return null;
|
|
42
|
+
|
|
43
|
+
const html = await res.text();
|
|
44
|
+
|
|
45
|
+
// Extract ytInitialData JSON from the page
|
|
46
|
+
const dataMatch = html.match(/var ytInitialData = ({.*?});<\/script>/s);
|
|
47
|
+
if (!dataMatch) return null;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(dataMatch[1]);
|
|
51
|
+
const contents = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents
|
|
52
|
+
?.sectionListRenderer?.contents;
|
|
53
|
+
|
|
54
|
+
if (!contents) return null;
|
|
55
|
+
|
|
56
|
+
const videos = [];
|
|
57
|
+
|
|
58
|
+
for (const section of contents) {
|
|
59
|
+
const items = section?.itemSectionRenderer?.contents;
|
|
60
|
+
if (!items) continue;
|
|
61
|
+
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
const video = item?.videoRenderer;
|
|
64
|
+
if (!video) continue;
|
|
65
|
+
|
|
66
|
+
const videoId = video.videoId;
|
|
67
|
+
const videoTitle = video?.title?.runs?.[0]?.text || '';
|
|
68
|
+
const videoDuration = parseDuration(video?.lengthText?.simpleText);
|
|
69
|
+
|
|
70
|
+
if (videoId && videoTitle) {
|
|
71
|
+
videos.push({
|
|
72
|
+
videoId,
|
|
73
|
+
title: videoTitle,
|
|
74
|
+
duration: videoDuration,
|
|
75
|
+
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (videos.length === 0) return null;
|
|
82
|
+
|
|
83
|
+
// Find best match by duration similarity
|
|
84
|
+
if (durationMs) {
|
|
85
|
+
const targetDuration = durationMs / 1000;
|
|
86
|
+
videos.sort((a, b) => {
|
|
87
|
+
const diffA = Math.abs(a.duration - targetDuration);
|
|
88
|
+
const diffB = Math.abs(b.duration - targetDuration);
|
|
89
|
+
return diffA - diffB;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Only accept matches within 10 seconds of target duration
|
|
93
|
+
if (Math.abs(videos[0].duration - targetDuration) <= 10) {
|
|
94
|
+
return videos[0];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If no duration match, return first result
|
|
99
|
+
return videos[0];
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fallback: construct a YouTube Music search URL and extract video ID
|
|
107
|
+
* from the redirect
|
|
108
|
+
*/
|
|
109
|
+
async function searchYouTubeFallback(query, durationMs) {
|
|
110
|
+
const encoded = encodeURIComponent(query + ' audio');
|
|
111
|
+
const url = `${YT_SEARCH_URL}?search_query=${encoded}`;
|
|
112
|
+
|
|
113
|
+
const res = await fetch(url, {
|
|
114
|
+
headers: {
|
|
115
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
116
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!res.ok) return null;
|
|
121
|
+
|
|
122
|
+
const html = await res.text();
|
|
123
|
+
|
|
124
|
+
// Extract video IDs from the page with regex
|
|
125
|
+
const videoIds = [...html.matchAll(/\"videoId\":\"([a-zA-Z0-9_-]{11})\"/g)]
|
|
126
|
+
.map(m => m[1])
|
|
127
|
+
.filter((id, idx, arr) => arr.indexOf(id) === idx); // unique
|
|
128
|
+
|
|
129
|
+
if (videoIds.length === 0) {
|
|
130
|
+
throw new Error(`No YouTube results found for "${query}"`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Return first video
|
|
134
|
+
return {
|
|
135
|
+
videoId: videoIds[0],
|
|
136
|
+
title: query,
|
|
137
|
+
duration: durationMs ? durationMs / 1000 : 0,
|
|
138
|
+
url: `https://www.youtube.com/watch?v=${videoIds[0]}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse duration string "M:SS" or "H:MM:SS" to seconds
|
|
144
|
+
*/
|
|
145
|
+
function parseDuration(str) {
|
|
146
|
+
if (!str) return 0;
|
|
147
|
+
const parts = str.split(':').map(Number);
|
|
148
|
+
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
149
|
+
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
package/src/tui/input.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
|
|
3
|
+
// ─── Key Input Handler ───────────────────────────────────
|
|
4
|
+
// Creates a raw-mode key listener that dispatches to callbacks
|
|
5
|
+
export function createInputHandler() {
|
|
6
|
+
const rl = readline.createInterface({
|
|
7
|
+
input: process.stdin,
|
|
8
|
+
output: process.stdout,
|
|
9
|
+
terminal: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Enable raw mode for keypress detection
|
|
13
|
+
if (process.stdin.isTTY) {
|
|
14
|
+
process.stdin.setRawMode(true);
|
|
15
|
+
}
|
|
16
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
17
|
+
|
|
18
|
+
const handlers = {};
|
|
19
|
+
let textBuffer = '';
|
|
20
|
+
let textMode = false;
|
|
21
|
+
let textCallback = null;
|
|
22
|
+
let textRenderCallback = null;
|
|
23
|
+
|
|
24
|
+
process.stdin.on('keypress', (str, key) => {
|
|
25
|
+
if (!key) return;
|
|
26
|
+
|
|
27
|
+
// Ctrl+C always exits
|
|
28
|
+
if (key.ctrl && key.name === 'c') {
|
|
29
|
+
cleanup();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Text input mode
|
|
34
|
+
if (textMode) {
|
|
35
|
+
if (key.name === 'return') {
|
|
36
|
+
textMode = false;
|
|
37
|
+
if (textCallback) textCallback(textBuffer);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (key.name === 'escape') {
|
|
41
|
+
textMode = false;
|
|
42
|
+
textBuffer = '';
|
|
43
|
+
if (handlers['escape']) handlers['escape']();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (key.name === 'backspace') {
|
|
47
|
+
textBuffer = textBuffer.slice(0, -1);
|
|
48
|
+
if (textRenderCallback) textRenderCallback(textBuffer);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (str && !key.ctrl && !key.meta) {
|
|
52
|
+
textBuffer += str;
|
|
53
|
+
if (textRenderCallback) textRenderCallback(textBuffer);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Navigation mode
|
|
60
|
+
const keyName = key.name;
|
|
61
|
+
if (handlers[keyName]) {
|
|
62
|
+
handlers[keyName](key);
|
|
63
|
+
}
|
|
64
|
+
if (key.name === 'space' && handlers['space']) {
|
|
65
|
+
handlers['space'](key);
|
|
66
|
+
}
|
|
67
|
+
if (str === 'a' && handlers['a']) handlers['a'](key);
|
|
68
|
+
if (str === 'A' && handlers['A']) handlers['A'](key);
|
|
69
|
+
if (str === 'q' && handlers['q']) handlers['q'](key);
|
|
70
|
+
if (str === 'Q' && handlers['Q']) handlers['Q'](key);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function cleanup() {
|
|
74
|
+
if (process.stdin.isTTY) {
|
|
75
|
+
process.stdin.setRawMode(false);
|
|
76
|
+
}
|
|
77
|
+
rl.close();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
// Register a key handler
|
|
82
|
+
on(keyName, callback) {
|
|
83
|
+
handlers[keyName] = callback;
|
|
84
|
+
return this;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// Remove a key handler
|
|
88
|
+
off(keyName) {
|
|
89
|
+
delete handlers[keyName];
|
|
90
|
+
return this;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// Clear all handlers
|
|
94
|
+
clearHandlers() {
|
|
95
|
+
for (const key of Object.keys(handlers)) {
|
|
96
|
+
delete handlers[key];
|
|
97
|
+
}
|
|
98
|
+
return this;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// Enter text input mode
|
|
102
|
+
startTextInput(initialValue = '', onChange, onSubmit) {
|
|
103
|
+
textBuffer = initialValue;
|
|
104
|
+
textMode = true;
|
|
105
|
+
textCallback = onSubmit;
|
|
106
|
+
textRenderCallback = onChange;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Exit text input mode
|
|
110
|
+
stopTextInput() {
|
|
111
|
+
textMode = false;
|
|
112
|
+
textBuffer = '';
|
|
113
|
+
textCallback = null;
|
|
114
|
+
textRenderCallback = null;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// Get current text buffer
|
|
118
|
+
getTextBuffer() {
|
|
119
|
+
return textBuffer;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// Cleanup
|
|
123
|
+
destroy() {
|
|
124
|
+
cleanup();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Simple Menu Selector ────────────────────────────────
|
|
130
|
+
// Returns a promise that resolves with the selected index
|
|
131
|
+
export function menuSelect(items, input, renderFn) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
let selectedIdx = 0;
|
|
134
|
+
|
|
135
|
+
function draw() {
|
|
136
|
+
renderFn(items, selectedIdx);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
input.clearHandlers();
|
|
140
|
+
|
|
141
|
+
input.on('up', () => {
|
|
142
|
+
selectedIdx = (selectedIdx - 1 + items.length) % items.length;
|
|
143
|
+
draw();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
input.on('down', () => {
|
|
147
|
+
selectedIdx = (selectedIdx + 1) % items.length;
|
|
148
|
+
draw();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
input.on('return', () => {
|
|
152
|
+
input.clearHandlers();
|
|
153
|
+
resolve(selectedIdx);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
input.on('escape', () => {
|
|
157
|
+
input.clearHandlers();
|
|
158
|
+
resolve(-1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
draw();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Checkbox Selector ───────────────────────────────────
|
|
166
|
+
// Returns a promise that resolves with a Set of selected indices
|
|
167
|
+
export function checkboxSelect(items, input, renderFn) {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
let selectedIdx = 0;
|
|
170
|
+
const checked = new Set();
|
|
171
|
+
|
|
172
|
+
// Start with all checked
|
|
173
|
+
items.forEach((_, i) => checked.add(i));
|
|
174
|
+
|
|
175
|
+
function draw() {
|
|
176
|
+
renderFn(items, selectedIdx, checked);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
input.clearHandlers();
|
|
180
|
+
|
|
181
|
+
input.on('up', () => {
|
|
182
|
+
selectedIdx = (selectedIdx - 1 + items.length) % items.length;
|
|
183
|
+
draw();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
input.on('down', () => {
|
|
187
|
+
selectedIdx = (selectedIdx + 1) % items.length;
|
|
188
|
+
draw();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
input.on('space', () => {
|
|
192
|
+
if (checked.has(selectedIdx)) {
|
|
193
|
+
checked.delete(selectedIdx);
|
|
194
|
+
} else {
|
|
195
|
+
checked.add(selectedIdx);
|
|
196
|
+
}
|
|
197
|
+
draw();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
input.on('a', () => {
|
|
201
|
+
if (checked.size === items.length) {
|
|
202
|
+
checked.clear();
|
|
203
|
+
} else {
|
|
204
|
+
items.forEach((_, i) => checked.add(i));
|
|
205
|
+
}
|
|
206
|
+
draw();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
input.on('return', () => {
|
|
210
|
+
input.clearHandlers();
|
|
211
|
+
resolve(checked);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
input.on('escape', () => {
|
|
215
|
+
input.clearHandlers();
|
|
216
|
+
resolve(null);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
draw();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Text Input ──────────────────────────────────────────
|
|
224
|
+
// Returns a promise that resolves with the entered text
|
|
225
|
+
export function textInput(input, renderFn, initialValue = '') {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
input.clearHandlers();
|
|
228
|
+
|
|
229
|
+
input.startTextInput(
|
|
230
|
+
initialValue,
|
|
231
|
+
(text) => renderFn(text), // onChange
|
|
232
|
+
(text) => { // onSubmit
|
|
233
|
+
input.clearHandlers();
|
|
234
|
+
resolve(text);
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
input.on('escape', () => {
|
|
239
|
+
input.stopTextInput();
|
|
240
|
+
input.clearHandlers();
|
|
241
|
+
resolve(null);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Initial render
|
|
245
|
+
renderFn(initialValue);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { COLORS, BOX, SYMBOLS } from '../constants.js';
|
|
2
|
+
|
|
3
|
+
// ─── Cursor Control ──────────────────────────────────────
|
|
4
|
+
export const cursor = {
|
|
5
|
+
hide: () => process.stdout.write('\x1b[?25l'),
|
|
6
|
+
show: () => process.stdout.write('\x1b[?25h'),
|
|
7
|
+
moveTo: (row, col) => process.stdout.write(`\x1b[${row};${col}H`),
|
|
8
|
+
moveUp: (n = 1) => process.stdout.write(`\x1b[${n}A`),
|
|
9
|
+
moveDown: (n = 1) => process.stdout.write(`\x1b[${n}B`),
|
|
10
|
+
saveCursor: () => process.stdout.write('\x1b[s'),
|
|
11
|
+
restoreCursor: () => process.stdout.write('\x1b[u'),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// ─── Screen Control ──────────────────────────────────────
|
|
15
|
+
export const screen = {
|
|
16
|
+
clear: () => process.stdout.write('\x1b[2J\x1b[H'),
|
|
17
|
+
clearLine: () => process.stdout.write('\x1b[2K'),
|
|
18
|
+
clearDown: () => process.stdout.write('\x1b[J'),
|
|
19
|
+
getSize: () => ({
|
|
20
|
+
width: process.stdout.columns || 80,
|
|
21
|
+
height: process.stdout.rows || 24,
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ─── Color Helpers ───────────────────────────────────────
|
|
26
|
+
export const c = {
|
|
27
|
+
reset: (str) => `${COLORS.reset}${str}${COLORS.reset}`,
|
|
28
|
+
bold: (str) => `${COLORS.bold}${str}${COLORS.reset}`,
|
|
29
|
+
dim: (str) => `${COLORS.dim}${str}${COLORS.reset}`,
|
|
30
|
+
italic: (str) => `${COLORS.italic}${str}${COLORS.reset}`,
|
|
31
|
+
|
|
32
|
+
red: (str) => `${COLORS.red}${str}${COLORS.reset}`,
|
|
33
|
+
green: (str) => `${COLORS.green}${str}${COLORS.reset}`,
|
|
34
|
+
yellow: (str) => `${COLORS.yellow}${str}${COLORS.reset}`,
|
|
35
|
+
blue: (str) => `${COLORS.blue}${str}${COLORS.reset}`,
|
|
36
|
+
magenta: (str) => `${COLORS.magenta}${str}${COLORS.reset}`,
|
|
37
|
+
cyan: (str) => `${COLORS.cyan}${str}${COLORS.reset}`,
|
|
38
|
+
white: (str) => `${COLORS.white}${str}${COLORS.reset}`,
|
|
39
|
+
|
|
40
|
+
brightGreen: (str) => `${COLORS.brightGreen}${str}${COLORS.reset}`,
|
|
41
|
+
brightCyan: (str) => `${COLORS.brightCyan}${str}${COLORS.reset}`,
|
|
42
|
+
brightYellow: (str) => `${COLORS.brightYellow}${str}${COLORS.reset}`,
|
|
43
|
+
brightMagenta: (str) => `${COLORS.brightMagenta}${str}${COLORS.reset}`,
|
|
44
|
+
brightBlue: (str) => `${COLORS.brightBlue}${str}${COLORS.reset}`,
|
|
45
|
+
brightWhite: (str) => `${COLORS.brightWhite}${str}${COLORS.reset}`,
|
|
46
|
+
|
|
47
|
+
// Gradient-ish effect using bright colors
|
|
48
|
+
gradient: (str) => `${COLORS.brightCyan}${str}${COLORS.reset}`,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ─── Box Drawing ─────────────────────────────────────────
|
|
52
|
+
export function drawBox(text, options = {}) {
|
|
53
|
+
const { width = 45, padding = 1 } = options;
|
|
54
|
+
const innerWidth = width - 2;
|
|
55
|
+
const lines = [];
|
|
56
|
+
|
|
57
|
+
// Top border
|
|
58
|
+
lines.push(c.cyan(`${BOX.topLeft}${BOX.horizontal.repeat(innerWidth)}${BOX.topRight}`));
|
|
59
|
+
|
|
60
|
+
// Padding top
|
|
61
|
+
for (let i = 0; i < padding; i++) {
|
|
62
|
+
lines.push(c.cyan(`${BOX.vertical}${' '.repeat(innerWidth)}${BOX.vertical}`));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Content lines
|
|
66
|
+
const textLines = Array.isArray(text) ? text : [text];
|
|
67
|
+
for (const line of textLines) {
|
|
68
|
+
const stripped = stripAnsi(line);
|
|
69
|
+
const padLen = Math.max(0, innerWidth - stripped.length);
|
|
70
|
+
const leftPad = Math.floor(padLen / 2);
|
|
71
|
+
const rightPad = padLen - leftPad;
|
|
72
|
+
lines.push(
|
|
73
|
+
c.cyan(BOX.vertical) +
|
|
74
|
+
' '.repeat(leftPad) + line + ' '.repeat(rightPad) +
|
|
75
|
+
c.cyan(BOX.vertical)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Padding bottom
|
|
80
|
+
for (let i = 0; i < padding; i++) {
|
|
81
|
+
lines.push(c.cyan(`${BOX.vertical}${' '.repeat(innerWidth)}${BOX.vertical}`));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Bottom border
|
|
85
|
+
lines.push(c.cyan(`${BOX.bottomLeft}${BOX.horizontal.repeat(innerWidth)}${BOX.bottomRight}`));
|
|
86
|
+
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Progress Bar ────────────────────────────────────────
|
|
91
|
+
export function progressBar(percent, width = 20) {
|
|
92
|
+
const filled = Math.round((percent / 100) * width);
|
|
93
|
+
const empty = width - filled;
|
|
94
|
+
const bar = c.brightGreen('█'.repeat(filled)) + c.dim('░'.repeat(empty));
|
|
95
|
+
const pct = `${Math.round(percent)}%`.padStart(4);
|
|
96
|
+
return `[${bar}] ${pct}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Table Drawing ───────────────────────────────────────
|
|
100
|
+
export function drawTable(headers, rows, options = {}) {
|
|
101
|
+
const { selected = -1, checked = new Set() } = options;
|
|
102
|
+
|
|
103
|
+
// Calculate column widths
|
|
104
|
+
const colWidths = headers.map((h, i) => {
|
|
105
|
+
const maxRow = rows.reduce((max, row) => Math.max(max, stripAnsi(String(row[i] || '')).length), 0);
|
|
106
|
+
return Math.max(stripAnsi(h).length, maxRow) + 2;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const totalWidth = colWidths.reduce((a, b) => a + b, 0) + headers.length + 1;
|
|
110
|
+
const lines = [];
|
|
111
|
+
|
|
112
|
+
// Top border
|
|
113
|
+
lines.push(
|
|
114
|
+
c.dim('┌') +
|
|
115
|
+
colWidths.map(w => c.dim('─'.repeat(w))).join(c.dim('┬')) +
|
|
116
|
+
c.dim('┐')
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Header
|
|
120
|
+
const headerLine = colWidths.map((w, i) => {
|
|
121
|
+
const text = ` ${headers[i]}`;
|
|
122
|
+
return c.bold(c.cyan(text.padEnd(w)));
|
|
123
|
+
}).join(c.dim('│'));
|
|
124
|
+
lines.push(c.dim('│') + headerLine + c.dim('│'));
|
|
125
|
+
|
|
126
|
+
// Header separator
|
|
127
|
+
lines.push(
|
|
128
|
+
c.dim('├') +
|
|
129
|
+
colWidths.map(w => c.dim('─'.repeat(w))).join(c.dim('┼')) +
|
|
130
|
+
c.dim('┤')
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Data rows
|
|
134
|
+
rows.forEach((row, rowIdx) => {
|
|
135
|
+
const isSelected = rowIdx === selected;
|
|
136
|
+
const isChecked = checked.has(rowIdx);
|
|
137
|
+
|
|
138
|
+
const rowLine = colWidths.map((w, i) => {
|
|
139
|
+
let text = ` ${String(row[i] || '')}`;
|
|
140
|
+
if (isSelected) {
|
|
141
|
+
text = c.brightCyan(text.padEnd(w));
|
|
142
|
+
} else if (isChecked) {
|
|
143
|
+
text = c.green(text.padEnd(w));
|
|
144
|
+
} else {
|
|
145
|
+
text = text.padEnd(w);
|
|
146
|
+
}
|
|
147
|
+
return text;
|
|
148
|
+
}).join(c.dim('│'));
|
|
149
|
+
|
|
150
|
+
const prefix = isSelected ? c.brightCyan('❯') : ' ';
|
|
151
|
+
const checkMark = isChecked ? c.green('✓') : ' ';
|
|
152
|
+
|
|
153
|
+
lines.push(c.dim('│') + rowLine + c.dim('│') + ` ${checkMark}`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Bottom border
|
|
157
|
+
lines.push(
|
|
158
|
+
c.dim('└') +
|
|
159
|
+
colWidths.map(w => c.dim('─'.repeat(w))).join(c.dim('┴')) +
|
|
160
|
+
c.dim('┘')
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Render ──────────────────────────────────────────────
|
|
167
|
+
export function render(content) {
|
|
168
|
+
screen.clear();
|
|
169
|
+
process.stdout.write(content + '\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Spinner ─────────────────────────────────────────────
|
|
173
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
174
|
+
|
|
175
|
+
export function createSpinner(text) {
|
|
176
|
+
let frameIdx = 0;
|
|
177
|
+
let interval = null;
|
|
178
|
+
let line = 0;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
start(row = 0) {
|
|
182
|
+
line = row;
|
|
183
|
+
cursor.hide();
|
|
184
|
+
interval = setInterval(() => {
|
|
185
|
+
cursor.moveTo(line, 1);
|
|
186
|
+
screen.clearLine();
|
|
187
|
+
const frame = c.cyan(SPINNER_FRAMES[frameIdx]);
|
|
188
|
+
process.stdout.write(` ${frame} ${text}`);
|
|
189
|
+
frameIdx = (frameIdx + 1) % SPINNER_FRAMES.length;
|
|
190
|
+
}, 80);
|
|
191
|
+
},
|
|
192
|
+
stop(finalText) {
|
|
193
|
+
if (interval) clearInterval(interval);
|
|
194
|
+
cursor.moveTo(line, 1);
|
|
195
|
+
screen.clearLine();
|
|
196
|
+
if (finalText) {
|
|
197
|
+
process.stdout.write(` ${c.green('✓')} ${finalText}\n`);
|
|
198
|
+
}
|
|
199
|
+
cursor.show();
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Utility: strip ANSI codes for width calculation ─────
|
|
205
|
+
export function stripAnsi(str) {
|
|
206
|
+
// eslint-disable-next-line no-control-regex
|
|
207
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
208
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { CONFIG_DIR, CONFIG_FILE, DEFAULT_DOWNLOAD_DIR } from '../constants.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
spotifyClientId: '',
|
|
6
|
+
spotifyClientSecret: '',
|
|
7
|
+
downloadDir: DEFAULT_DOWNLOAD_DIR,
|
|
8
|
+
audioQuality: 'best',
|
|
9
|
+
audioFormat: 'mp3',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load config from ~/.ssdl/config.json
|
|
14
|
+
* Creates default config if it doesn't exist
|
|
15
|
+
*/
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
try {
|
|
18
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
19
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
23
|
+
saveConfig(DEFAULT_CONFIG);
|
|
24
|
+
return { ...DEFAULT_CONFIG };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const raw = readFileSync(CONFIG_FILE, 'utf-8');
|
|
28
|
+
const config = JSON.parse(raw);
|
|
29
|
+
return { ...DEFAULT_CONFIG, ...config };
|
|
30
|
+
} catch {
|
|
31
|
+
return { ...DEFAULT_CONFIG };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Save config to ~/.ssdl/config.json
|
|
37
|
+
*/
|
|
38
|
+
export function saveConfig(config) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
41
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error('Failed to save config:', err.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if Spotify credentials are configured
|
|
51
|
+
*/
|
|
52
|
+
export function hasCredentials(config) {
|
|
53
|
+
return !!(config.spotifyClientId && config.spotifyClientSecret);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Update specific config values
|
|
58
|
+
*/
|
|
59
|
+
export function updateConfig(updates) {
|
|
60
|
+
const config = loadConfig();
|
|
61
|
+
const updated = { ...config, ...updates };
|
|
62
|
+
saveConfig(updated);
|
|
63
|
+
return updated;
|
|
64
|
+
}
|