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