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,100 @@
|
|
|
1
|
+
import { render, c } from '../tui/renderer.js';
|
|
2
|
+
import { textInput, menuSelect } from '../tui/input.js';
|
|
3
|
+
import { searchTracks } from '../services/spotify.js';
|
|
4
|
+
import { formatDuration, truncate } from '../utils/format.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Show the search screen — search Spotify for tracks
|
|
8
|
+
* @returns {Promise<object|null>} Selected track object, or null if cancelled
|
|
9
|
+
*/
|
|
10
|
+
export async function showSearch(input) {
|
|
11
|
+
// Step 1: Get search query
|
|
12
|
+
const query = await textInput(input, (text) => {
|
|
13
|
+
let content = '\n';
|
|
14
|
+
content += c.brightCyan(' Search Spotify\n\n');
|
|
15
|
+
content += ` > ${text}${c.dim('|')}\n\n`;
|
|
16
|
+
|
|
17
|
+
if (!text.trim()) {
|
|
18
|
+
content += c.dim(' Type a song name, artist, or both\n');
|
|
19
|
+
} else {
|
|
20
|
+
content += c.dim(` Press Enter to search for "${text}"\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
content += '\n' + c.dim(' Enter search | Esc back');
|
|
24
|
+
render(content);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!query) return null;
|
|
28
|
+
|
|
29
|
+
// Step 2: Show loading with dots animation
|
|
30
|
+
render('\n' + c.cyan(' Searching Spotify...'));
|
|
31
|
+
|
|
32
|
+
// Step 3: Search Spotify
|
|
33
|
+
let results;
|
|
34
|
+
try {
|
|
35
|
+
results = await searchTracks(query, 10);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
render(
|
|
38
|
+
'\n' +
|
|
39
|
+
c.red(` Search failed: ${err.message}\n\n`) +
|
|
40
|
+
c.dim(' Press Enter to go back')
|
|
41
|
+
);
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
input.clearHandlers();
|
|
44
|
+
input.on('return', () => { input.clearHandlers(); resolve(null); });
|
|
45
|
+
input.on('escape', () => { input.clearHandlers(); resolve(null); });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!results || results.length === 0) {
|
|
50
|
+
render(
|
|
51
|
+
'\n' +
|
|
52
|
+
c.yellow(` No results for "${query}"\n\n`) +
|
|
53
|
+
c.dim(' Try a different search term\n\n') +
|
|
54
|
+
c.dim(' Press Enter to go back')
|
|
55
|
+
);
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
input.clearHandlers();
|
|
58
|
+
input.on('return', () => { input.clearHandlers(); resolve(null); });
|
|
59
|
+
input.on('escape', () => { input.clearHandlers(); resolve(null); });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Step 4: Show results with aligned columns
|
|
64
|
+
const selectedIdx = await menuSelect(results, input, (items, selected) => {
|
|
65
|
+
let content = '\n';
|
|
66
|
+
content += c.brightCyan(` Results for "${query}"`) + c.dim(` (${items.length} found)\n\n`);
|
|
67
|
+
|
|
68
|
+
// Calculate column widths for alignment
|
|
69
|
+
const maxTitle = 30;
|
|
70
|
+
const maxArtist = 20;
|
|
71
|
+
|
|
72
|
+
items.forEach((track, i) => {
|
|
73
|
+
const isActive = i === selected;
|
|
74
|
+
const prefix = isActive ? c.brightCyan(' > ') : ' ';
|
|
75
|
+
const num = c.dim(`${String(i + 1).padStart(2)}.`);
|
|
76
|
+
const title = truncate(track.title, maxTitle);
|
|
77
|
+
const artist = truncate(track.artist, maxArtist);
|
|
78
|
+
const dur = formatDuration(track.duration);
|
|
79
|
+
|
|
80
|
+
if (isActive) {
|
|
81
|
+
content += `${prefix}${num} ${c.brightWhite(title.padEnd(maxTitle))} ${c.cyan(artist.padEnd(maxArtist))} ${c.dim(dur)}\n`;
|
|
82
|
+
} else {
|
|
83
|
+
content += `${prefix}${num} ${title.padEnd(maxTitle)} ${c.dim(artist.padEnd(maxArtist))} ${c.dim(dur)}\n`;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Show details of selected track
|
|
88
|
+
const sel = items[selected];
|
|
89
|
+
content += '\n';
|
|
90
|
+
content += c.dim(' ─────────────────────────────────────────────────────\n');
|
|
91
|
+
content += ` ${c.brightWhite(sel.title)}\n`;
|
|
92
|
+
content += ` ${c.cyan(sel.artist)} ${c.dim('on')} ${c.dim(sel.album)}\n`;
|
|
93
|
+
content += ` ${c.dim(formatDuration(sel.duration))}\n`;
|
|
94
|
+
content += '\n' + c.dim(' up/down navigate | Enter download | Esc back');
|
|
95
|
+
render(content);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (selectedIdx === -1) return null;
|
|
99
|
+
return results[selectedIdx];
|
|
100
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { render, drawTable, c } from '../tui/renderer.js';
|
|
2
|
+
import { checkboxSelect } from '../tui/input.js';
|
|
3
|
+
import { formatDuration, truncate } from '../utils/format.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Show the track list / playlist preview with checkbox selection
|
|
7
|
+
* @param {object} data - Playlist/album data with tracks array
|
|
8
|
+
* @param {string} type - 'track' | 'playlist' | 'album'
|
|
9
|
+
* @returns {Promise<object[]|null>} Selected tracks, or null if cancelled
|
|
10
|
+
*/
|
|
11
|
+
export async function showTrackList(data, type, input) {
|
|
12
|
+
// Single track — data is already an array like [trackObj], return as-is
|
|
13
|
+
if (type === 'track') {
|
|
14
|
+
return data;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const tracks = data.tracks;
|
|
18
|
+
|
|
19
|
+
const selectedSet = await checkboxSelect(tracks, input, (items, selectedIdx, checked) => {
|
|
20
|
+
let content = '\n';
|
|
21
|
+
|
|
22
|
+
// Header with metadata
|
|
23
|
+
if (type === 'playlist') {
|
|
24
|
+
content += c.brightCyan(` Playlist: ${data.name}\n`);
|
|
25
|
+
content += c.dim(` by ${data.owner} | ${tracks.length} tracks`);
|
|
26
|
+
const totalMs = tracks.reduce((s, t) => s + (t.duration || 0), 0);
|
|
27
|
+
content += c.dim(` | ${formatDuration(totalMs)} total\n`);
|
|
28
|
+
} else if (type === 'album') {
|
|
29
|
+
content += c.brightCyan(` Album: ${data.name}\n`);
|
|
30
|
+
content += c.dim(` by ${data.artist} | ${tracks.length} tracks`);
|
|
31
|
+
if (data.releaseDate) content += c.dim(` | ${data.releaseDate.slice(0, 4)}`);
|
|
32
|
+
content += '\n';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
content += c.dim(' ─────────────────────────────────────────────────────\n');
|
|
36
|
+
content += '\n';
|
|
37
|
+
|
|
38
|
+
// Track table
|
|
39
|
+
const headers = ['', '#', 'Title', 'Artist', 'Time'];
|
|
40
|
+
const rows = items.map((track, i) => [
|
|
41
|
+
checked.has(i) ? c.green('x') : c.dim('-'),
|
|
42
|
+
c.dim(String(i + 1).padStart(2)),
|
|
43
|
+
truncate(track.title, 28),
|
|
44
|
+
truncate(track.artist, 18),
|
|
45
|
+
formatDuration(track.duration),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Show a window of rows around the selected index (max ~15 visible)
|
|
49
|
+
const maxVisible = Math.min(15, process.stdout.rows - 12);
|
|
50
|
+
let startIdx = Math.max(0, selectedIdx - Math.floor(maxVisible / 2));
|
|
51
|
+
let endIdx = Math.min(rows.length, startIdx + maxVisible);
|
|
52
|
+
if (endIdx - startIdx < maxVisible) {
|
|
53
|
+
startIdx = Math.max(0, endIdx - maxVisible);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const visibleRows = rows.slice(startIdx, endIdx);
|
|
57
|
+
const adjustedSelected = selectedIdx - startIdx;
|
|
58
|
+
|
|
59
|
+
content += drawTable(headers, visibleRows, {
|
|
60
|
+
selected: adjustedSelected,
|
|
61
|
+
checked: new Set(
|
|
62
|
+
[...checked].filter(i => i >= startIdx && i < endIdx).map(i => i - startIdx)
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
content += '\n\n';
|
|
67
|
+
|
|
68
|
+
// Status bar
|
|
69
|
+
const selectedCount = checked.size;
|
|
70
|
+
const bar = ` ${c.brightCyan(`${selectedCount}`)}/${tracks.length} selected`;
|
|
71
|
+
const scroll = rows.length > maxVisible
|
|
72
|
+
? c.dim(` | ${startIdx + 1}-${endIdx} of ${rows.length}`)
|
|
73
|
+
: '';
|
|
74
|
+
content += bar + scroll + '\n';
|
|
75
|
+
|
|
76
|
+
content += c.dim(' up/down navigate | Space toggle | A select all | Enter download | Esc back');
|
|
77
|
+
|
|
78
|
+
render(content);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!selectedSet) return null;
|
|
82
|
+
|
|
83
|
+
// Return selected tracks
|
|
84
|
+
return tracks.filter((_, i) => selectedSet.has(i));
|
|
85
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, c } from '../tui/renderer.js';
|
|
2
|
+
import { textInput } from '../tui/input.js';
|
|
3
|
+
import { parseSpotifyUrl } from '../services/spotify.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Show the URL input screen with live validation
|
|
7
|
+
* @returns {Promise<string|null>} The entered URL, or null if cancelled
|
|
8
|
+
*/
|
|
9
|
+
export async function showURLInput(input) {
|
|
10
|
+
const url = await textInput(input, (text) => {
|
|
11
|
+
let content = '\n';
|
|
12
|
+
content += c.brightCyan(' Paste a Spotify URL\n\n');
|
|
13
|
+
|
|
14
|
+
// URL input with live validation indicator
|
|
15
|
+
const parsed = text.trim() ? parseSpotifyUrl(text.trim()) : null;
|
|
16
|
+
const cursor = c.dim('|');
|
|
17
|
+
|
|
18
|
+
if (!text.trim()) {
|
|
19
|
+
content += c.dim(' > ') + cursor + '\n';
|
|
20
|
+
content += '\n';
|
|
21
|
+
content += c.dim(' Supported:\n');
|
|
22
|
+
content += c.dim(' open.spotify.com/track/...\n');
|
|
23
|
+
content += c.dim(' open.spotify.com/album/...\n');
|
|
24
|
+
content += c.dim(' open.spotify.com/playlist/...\n');
|
|
25
|
+
} else if (parsed) {
|
|
26
|
+
const typeLabel = parsed.type.charAt(0).toUpperCase() + parsed.type.slice(1);
|
|
27
|
+
content += c.green(' > ') + `${text}${cursor}\n`;
|
|
28
|
+
content += '\n';
|
|
29
|
+
content += c.green(` Valid ${typeLabel} URL detected\n`);
|
|
30
|
+
} else {
|
|
31
|
+
content += c.red(' > ') + `${text}${cursor}\n`;
|
|
32
|
+
content += '\n';
|
|
33
|
+
content += c.red(' Not a valid Spotify URL\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
content += '\n' + c.dim(' Enter continue | Esc back');
|
|
37
|
+
render(content);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return url && url.trim() ? url.trim() : null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { render, c } from '../tui/renderer.js';
|
|
2
|
+
import { menuSelect } from '../tui/input.js';
|
|
3
|
+
import { APP_VERSION } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
const BANNER = [
|
|
6
|
+
' ███████╗███████╗██████╗ ██╗ ',
|
|
7
|
+
' ██╔════╝██╔════╝██╔══██╗██║ ',
|
|
8
|
+
' ███████╗███████╗██║ ██║██║ ',
|
|
9
|
+
' ╚════██║╚════██║██║ ██║██║ ',
|
|
10
|
+
' ███████║███████║██████╔╝███████╗',
|
|
11
|
+
' ╚══════╝╚══════╝╚═════╝ ╚══════╝',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const MENU_ITEMS = [
|
|
15
|
+
{ label: 'Paste a Spotify URL (track/playlist/album)', value: 'url' },
|
|
16
|
+
{ label: 'Search for a song', value: 'search' },
|
|
17
|
+
{ label: 'Settings', value: 'settings' },
|
|
18
|
+
{ label: 'Exit', value: 'exit' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Show the welcome screen with main menu
|
|
23
|
+
* @returns {Promise<string>} Selected action: 'url' | 'search' | 'settings' | 'exit'
|
|
24
|
+
*/
|
|
25
|
+
export async function showWelcome(input) {
|
|
26
|
+
const selectedIdx = await menuSelect(MENU_ITEMS, input, (items, selected) => {
|
|
27
|
+
let content = '\n';
|
|
28
|
+
|
|
29
|
+
// Banner with gradient
|
|
30
|
+
const grad = [
|
|
31
|
+
'\x1b[38;5;43m', // teal
|
|
32
|
+
'\x1b[38;5;44m', // cyan
|
|
33
|
+
'\x1b[38;5;45m', // bright cyan
|
|
34
|
+
'\x1b[38;5;44m', // cyan
|
|
35
|
+
'\x1b[38;5;43m', // teal
|
|
36
|
+
'\x1b[38;5;42m', // green-teal
|
|
37
|
+
];
|
|
38
|
+
const reset = '\x1b[0m';
|
|
39
|
+
|
|
40
|
+
content += BANNER.map((line, i) => `${grad[i]}${line}${reset}`).join('\n');
|
|
41
|
+
content += '\n';
|
|
42
|
+
content += c.dim(' Spotify Song Downloader') + c.dim(`${' '.repeat(10)}v${APP_VERSION}`) + '\n';
|
|
43
|
+
content += c.dim(' ────────────────────────────────────') + '\n\n';
|
|
44
|
+
|
|
45
|
+
items.forEach((item, i) => {
|
|
46
|
+
const prefix = i === selected
|
|
47
|
+
? c.brightCyan(' > ')
|
|
48
|
+
: ' ';
|
|
49
|
+
const label = i === selected
|
|
50
|
+
? c.brightWhite(item.label)
|
|
51
|
+
: c.dim(item.label);
|
|
52
|
+
content += `${prefix}${label}\n`;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
content += '\n' + c.dim(' up/down navigate | Enter select | Ctrl+C quit');
|
|
56
|
+
|
|
57
|
+
render(content);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (selectedIdx === -1) return 'exit';
|
|
61
|
+
return MENU_ITEMS[selectedIdx].value;
|
|
62
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, mkdirSync, statSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { sanitizeFilename } from '../utils/format.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Download a track from YouTube using yt-dlp
|
|
8
|
+
* @param {string} youtubeUrl - YouTube video URL
|
|
9
|
+
* @param {object} trackInfo - Track metadata { title, artist }
|
|
10
|
+
* @param {string} outputDir - Directory to save the file
|
|
11
|
+
* @param {function} onProgress - Callback with (percent, status)
|
|
12
|
+
* @returns {Promise<string>} - Path to the downloaded file
|
|
13
|
+
*/
|
|
14
|
+
export async function downloadTrack(youtubeUrl, trackInfo, outputDir, onProgress) {
|
|
15
|
+
// Ensure output directory exists
|
|
16
|
+
if (!existsSync(outputDir)) {
|
|
17
|
+
mkdirSync(outputDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const filename = sanitizeFilename(`${trackInfo.title} — ${trackInfo.artist}`);
|
|
21
|
+
const outputPath = join(outputDir, `${filename}.%(ext)s`);
|
|
22
|
+
const expectedPath = join(outputDir, `${filename}.mp3`);
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const args = [
|
|
26
|
+
youtubeUrl,
|
|
27
|
+
'--extract-audio',
|
|
28
|
+
'--audio-format', 'mp3',
|
|
29
|
+
'--audio-quality', '0', // Best quality
|
|
30
|
+
'--output', outputPath,
|
|
31
|
+
'--no-playlist',
|
|
32
|
+
'--no-warnings',
|
|
33
|
+
'--progress',
|
|
34
|
+
'--newline', // Each progress update on a new line
|
|
35
|
+
'--no-check-certificates',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const proc = spawn('yt-dlp', args, {
|
|
39
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let lastPercent = 0;
|
|
43
|
+
let stderr = '';
|
|
44
|
+
|
|
45
|
+
proc.stdout.on('data', (data) => {
|
|
46
|
+
const lines = data.toString().split('\n');
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
// Parse progress lines like: [download] 45.2% of 4.81MiB at 1.23MiB/s ETA 00:03
|
|
49
|
+
const progressMatch = line.match(/\[download\]\s+([\d.]+)%/);
|
|
50
|
+
if (progressMatch) {
|
|
51
|
+
const percent = parseFloat(progressMatch[1]);
|
|
52
|
+
if (percent > lastPercent) {
|
|
53
|
+
lastPercent = percent;
|
|
54
|
+
if (onProgress) onProgress(percent, 'Downloading...');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Post-processing indication
|
|
59
|
+
if (line.includes('[ExtractAudio]') || line.includes('Post-process')) {
|
|
60
|
+
if (onProgress) onProgress(lastPercent, 'Converting...');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
proc.stderr.on('data', (data) => {
|
|
66
|
+
stderr += data.toString();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
proc.on('close', (code) => {
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
if (onProgress) onProgress(100, 'Done');
|
|
72
|
+
resolve(expectedPath);
|
|
73
|
+
} else {
|
|
74
|
+
reject(new Error(`yt-dlp exited with code ${code}: ${stderr}`));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
proc.on('error', (err) => {
|
|
79
|
+
reject(new Error(`Failed to spawn yt-dlp: ${err.message}`));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get the file size of a downloaded file
|
|
86
|
+
*/
|
|
87
|
+
export function getFileSize(filePath) {
|
|
88
|
+
try {
|
|
89
|
+
return statSync(filePath).size;
|
|
90
|
+
} catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import NodeID3 from 'node-id3';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Embed metadata (ID3 tags) into an MP3 file
|
|
5
|
+
* @param {string} filePath - Path to the MP3 file
|
|
6
|
+
* @param {object} meta - Track metadata
|
|
7
|
+
* @param {string} meta.title - Track title
|
|
8
|
+
* @param {string} meta.artist - Artist name
|
|
9
|
+
* @param {string} meta.album - Album name
|
|
10
|
+
* @param {string} [meta.artworkUrl] - URL to album artwork
|
|
11
|
+
* @param {number} [meta.trackNumber] - Track number
|
|
12
|
+
* @param {string} [meta.releaseDate] - Release date
|
|
13
|
+
*/
|
|
14
|
+
export async function embedMetadata(filePath, meta) {
|
|
15
|
+
const tags = {
|
|
16
|
+
title: meta.title,
|
|
17
|
+
artist: meta.artist,
|
|
18
|
+
album: meta.album,
|
|
19
|
+
trackNumber: meta.trackNumber ? String(meta.trackNumber) : undefined,
|
|
20
|
+
year: meta.releaseDate ? meta.releaseDate.split('-')[0] : undefined,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Download and embed album artwork
|
|
24
|
+
if (meta.artworkUrl) {
|
|
25
|
+
try {
|
|
26
|
+
const artworkRes = await fetch(meta.artworkUrl);
|
|
27
|
+
if (artworkRes.ok) {
|
|
28
|
+
const buffer = Buffer.from(await artworkRes.arrayBuffer());
|
|
29
|
+
tags.image = {
|
|
30
|
+
mime: 'image/jpeg',
|
|
31
|
+
type: { id: 3, name: 'front cover' },
|
|
32
|
+
description: 'Album Artwork',
|
|
33
|
+
imageBuffer: buffer,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Skip artwork if download fails — not critical
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Write tags to file
|
|
42
|
+
const success = NodeID3.write(tags, filePath);
|
|
43
|
+
if (!success) {
|
|
44
|
+
throw new Error(`Failed to write ID3 tags to ${filePath}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { SPOTIFY_TOKEN_URL, SPOTIFY_API_BASE } from '../constants.js';
|
|
2
|
+
|
|
3
|
+
let accessToken = null;
|
|
4
|
+
let tokenExpiry = 0;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Authenticate with Spotify using Client Credentials flow
|
|
8
|
+
*/
|
|
9
|
+
export async function authenticate(clientId, clientSecret) {
|
|
10
|
+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
11
|
+
|
|
12
|
+
const res = await fetch(SPOTIFY_TOKEN_URL, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'Authorization': `Basic ${credentials}`,
|
|
16
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
17
|
+
},
|
|
18
|
+
body: 'grant_type=client_credentials',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const err = await res.json().catch(() => ({}));
|
|
23
|
+
throw new Error(`Spotify auth failed: ${err.error_description || res.statusText}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
accessToken = data.access_token;
|
|
28
|
+
tokenExpiry = Date.now() + (data.expires_in * 1000) - 60000; // Refresh 1 min early
|
|
29
|
+
|
|
30
|
+
return accessToken;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if the current token is still valid
|
|
35
|
+
*/
|
|
36
|
+
export function isAuthenticated() {
|
|
37
|
+
return accessToken && Date.now() < tokenExpiry;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Make an authenticated request to the Spotify API
|
|
42
|
+
*/
|
|
43
|
+
async function spotifyFetch(endpoint) {
|
|
44
|
+
if (!accessToken) {
|
|
45
|
+
throw new Error('Not authenticated. Call authenticate() first.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const res = await fetch(`${SPOTIFY_API_BASE}${endpoint}`, {
|
|
49
|
+
headers: {
|
|
50
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (res.status === 401) {
|
|
55
|
+
throw new Error('Spotify token expired. Re-authenticate.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new Error(`Spotify API error: ${res.status} ${res.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return res.json();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a Spotify URL and extract type + ID
|
|
67
|
+
* Supports:
|
|
68
|
+
* https://open.spotify.com/track/ID
|
|
69
|
+
* https://open.spotify.com/album/ID
|
|
70
|
+
* https://open.spotify.com/playlist/ID
|
|
71
|
+
* spotify:track:ID
|
|
72
|
+
*/
|
|
73
|
+
export function parseSpotifyUrl(url) {
|
|
74
|
+
// Web URL format
|
|
75
|
+
const webMatch = url.match(
|
|
76
|
+
/open\.spotify\.com\/(track|album|playlist)\/([a-zA-Z0-9]+)/
|
|
77
|
+
);
|
|
78
|
+
if (webMatch) {
|
|
79
|
+
return { type: webMatch[1], id: webMatch[2] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// URI format (spotify:track:ID)
|
|
83
|
+
const uriMatch = url.match(
|
|
84
|
+
/spotify:(track|album|playlist):([a-zA-Z0-9]+)/
|
|
85
|
+
);
|
|
86
|
+
if (uriMatch) {
|
|
87
|
+
return { type: uriMatch[1], id: uriMatch[2] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get a single track's metadata
|
|
95
|
+
*/
|
|
96
|
+
export async function getTrack(id) {
|
|
97
|
+
const data = await spotifyFetch(`/tracks/${id}`);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
id: data.id,
|
|
101
|
+
title: data.name,
|
|
102
|
+
artist: data.artists.map(a => a.name).join(', '),
|
|
103
|
+
album: data.album.name,
|
|
104
|
+
duration: data.duration_ms,
|
|
105
|
+
artworkUrl: data.album.images?.[0]?.url || null,
|
|
106
|
+
releaseDate: data.album.release_date,
|
|
107
|
+
trackNumber: data.track_number,
|
|
108
|
+
url: data.external_urls?.spotify,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get a playlist's metadata and all tracks
|
|
114
|
+
*/
|
|
115
|
+
export async function getPlaylist(id) {
|
|
116
|
+
const data = await spotifyFetch(`/playlists/${id}`);
|
|
117
|
+
|
|
118
|
+
let tracks = data.tracks.items
|
|
119
|
+
.filter(item => item.track)
|
|
120
|
+
.map(item => ({
|
|
121
|
+
id: item.track.id,
|
|
122
|
+
title: item.track.name,
|
|
123
|
+
artist: item.track.artists.map(a => a.name).join(', '),
|
|
124
|
+
album: item.track.album.name,
|
|
125
|
+
duration: item.track.duration_ms,
|
|
126
|
+
artworkUrl: item.track.album.images?.[0]?.url || null,
|
|
127
|
+
releaseDate: item.track.album.release_date,
|
|
128
|
+
trackNumber: item.track.track_number,
|
|
129
|
+
url: item.track.external_urls?.spotify,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// Handle pagination — playlists can have > 100 tracks
|
|
133
|
+
let next = data.tracks.next;
|
|
134
|
+
while (next) {
|
|
135
|
+
const nextUrl = next.replace(SPOTIFY_API_BASE, '');
|
|
136
|
+
const nextData = await spotifyFetch(nextUrl);
|
|
137
|
+
const moreTracks = nextData.items
|
|
138
|
+
.filter(item => item.track)
|
|
139
|
+
.map(item => ({
|
|
140
|
+
id: item.track.id,
|
|
141
|
+
title: item.track.name,
|
|
142
|
+
artist: item.track.artists.map(a => a.name).join(', '),
|
|
143
|
+
album: item.track.album.name,
|
|
144
|
+
duration: item.track.duration_ms,
|
|
145
|
+
artworkUrl: item.track.album.images?.[0]?.url || null,
|
|
146
|
+
releaseDate: item.track.album.release_date,
|
|
147
|
+
trackNumber: item.track.track_number,
|
|
148
|
+
url: item.track.external_urls?.spotify,
|
|
149
|
+
}));
|
|
150
|
+
tracks = tracks.concat(moreTracks);
|
|
151
|
+
next = nextData.next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
name: data.name,
|
|
156
|
+
description: data.description,
|
|
157
|
+
owner: data.owner.display_name,
|
|
158
|
+
totalTracks: tracks.length,
|
|
159
|
+
artworkUrl: data.images?.[0]?.url || null,
|
|
160
|
+
tracks,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get an album's metadata and all tracks
|
|
166
|
+
*/
|
|
167
|
+
export async function getAlbum(id) {
|
|
168
|
+
const data = await spotifyFetch(`/albums/${id}`);
|
|
169
|
+
|
|
170
|
+
const tracks = data.tracks.items.map(item => ({
|
|
171
|
+
id: item.id,
|
|
172
|
+
title: item.name,
|
|
173
|
+
artist: item.artists.map(a => a.name).join(', '),
|
|
174
|
+
album: data.name,
|
|
175
|
+
duration: item.duration_ms,
|
|
176
|
+
artworkUrl: data.images?.[0]?.url || null,
|
|
177
|
+
releaseDate: data.release_date,
|
|
178
|
+
trackNumber: item.track_number,
|
|
179
|
+
url: item.external_urls?.spotify,
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
name: data.name,
|
|
184
|
+
artist: data.artists.map(a => a.name).join(', '),
|
|
185
|
+
totalTracks: tracks.length,
|
|
186
|
+
releaseDate: data.release_date,
|
|
187
|
+
artworkUrl: data.images?.[0]?.url || null,
|
|
188
|
+
tracks,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Search Spotify for tracks
|
|
194
|
+
*/
|
|
195
|
+
export async function searchTracks(query, limit = 10) {
|
|
196
|
+
const encoded = encodeURIComponent(query);
|
|
197
|
+
const data = await spotifyFetch(`/search?q=${encoded}&type=track&limit=${limit}`);
|
|
198
|
+
|
|
199
|
+
return data.tracks.items.map(item => ({
|
|
200
|
+
id: item.id,
|
|
201
|
+
title: item.name,
|
|
202
|
+
artist: item.artists.map(a => a.name).join(', '),
|
|
203
|
+
album: item.album.name,
|
|
204
|
+
duration: item.duration_ms,
|
|
205
|
+
artworkUrl: item.album.images?.[0]?.url || null,
|
|
206
|
+
url: item.external_urls?.spotify,
|
|
207
|
+
}));
|
|
208
|
+
}
|