spotify.js
1 import { cli, Strategy } from '@jackwener/opencli/registry'; 2 import { CliError } from '@jackwener/opencli/errors'; 3 import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; 4 import { createServer } from 'http'; 5 import { homedir } from 'os'; 6 import { join } from 'path'; 7 import { exec } from 'child_process'; 8 import { assertSpotifyCredentialsConfigured, getFirstSpotifyTrack, mapSpotifyTrackResults, parseDotEnv, resolveSpotifyCredentials, } from './utils.js'; 9 // ── Credentials ─────────────────────────────────────────────────────────────── 10 // Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET as environment variables, 11 // or place them in ~/.opencli/spotify.env: 12 // SPOTIFY_CLIENT_ID=your_id 13 // SPOTIFY_CLIENT_SECRET=your_secret 14 const ENV_FILE = join(homedir(), '.opencli', 'spotify.env'); 15 function loadEnv() { 16 if (!existsSync(ENV_FILE)) 17 return {}; 18 return parseDotEnv(readFileSync(ENV_FILE, 'utf-8')); 19 } 20 const env = loadEnv(); 21 const credentials = resolveSpotifyCredentials(env); 22 const CLIENT_ID = credentials.clientId; 23 const CLIENT_SECRET = credentials.clientSecret; 24 const REDIRECT_URI = 'http://127.0.0.1:8888/callback'; 25 const SCOPES = [ 26 'user-read-playback-state', 27 'user-modify-playback-state', 28 'user-read-currently-playing', 29 'playlist-read-private', 30 ].join(' '); 31 // ── Token storage ───────────────────────────────────────────────────────────── 32 const TOKEN_FILE = join(homedir(), '.opencli', 'spotify-tokens.json'); 33 function loadTokens() { 34 try { 35 return JSON.parse(readFileSync(TOKEN_FILE, 'utf-8')); 36 } 37 catch { 38 return null; 39 } 40 } 41 function saveTokens(tokens) { 42 mkdirSync(join(homedir(), '.opencli'), { recursive: true }); 43 writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2)); 44 } 45 async function refreshAccessToken(refreshToken) { 46 const res = await fetch('https://accounts.spotify.com/api/token', { 47 method: 'POST', 48 headers: { 49 'Content-Type': 'application/x-www-form-urlencoded', 50 Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), 51 }, 52 body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }), 53 }); 54 if (!res.ok) { 55 const err = await res.json().catch(() => ({})); 56 throw new CliError('REFRESH_FAILED', err?.error_description || `Token refresh failed (${res.status})`); 57 } 58 const data = await res.json(); 59 const tokens = { 60 access_token: data.access_token, 61 refresh_token: data.refresh_token || refreshToken, 62 expires_at: Date.now() + data.expires_in * 1000, 63 }; 64 saveTokens(tokens); 65 return tokens.access_token; 66 } 67 async function getToken() { 68 const tokens = loadTokens(); 69 if (!tokens) 70 throw new CliError('AUTH_REQUIRED', 'Not authenticated. Run: opencli spotify auth'); 71 if (!tokens.access_token || !tokens.refresh_token || !(tokens.expires_at > 0)) { 72 throw new CliError('AUTH_CORRUPTED', 'Token file is corrupted. Run: opencli spotify auth'); 73 } 74 if (Date.now() > tokens.expires_at - 60_000) 75 return refreshAccessToken(tokens.refresh_token); 76 return tokens.access_token; 77 } 78 // ── Spotify API helper ──────────────────────────────────────────────────────── 79 async function api(method, path, body) { 80 const token = await getToken(); 81 const res = await fetch(`https://api.spotify.com/v1${path}`, { 82 method, 83 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, 84 body: body ? JSON.stringify(body) : undefined, 85 }); 86 if (res.status === 204 || res.status === 202) 87 return null; 88 if (!res.ok) { 89 const err = await res.json().catch(() => ({})); 90 throw new CliError('API_ERROR', err?.error?.message || `Spotify API error ${res.status}`); 91 } 92 return res.json(); 93 } 94 async function findTrackUri(query) { 95 const data = await api('GET', `/search?q=${encodeURIComponent(query)}&type=track&limit=1`); 96 const track = getFirstSpotifyTrack(data); 97 if (!track) 98 throw new CliError('EMPTY_RESULT', `No track found for: ${query}`); 99 return track; 100 } 101 function openBrowser(url) { 102 const cmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`; 103 exec(cmd); 104 } 105 // ── Commands ────────────────────────────────────────────────────────────────── 106 cli({ 107 site: 'spotify', 108 name: 'auth', 109 description: 'Authenticate with Spotify (OAuth — run once)', 110 strategy: Strategy.PUBLIC, 111 browser: false, 112 args: [], 113 columns: ['status'], 114 func: async () => { 115 assertSpotifyCredentialsConfigured(credentials, ENV_FILE); 116 return new Promise((resolve, reject) => { 117 const server = createServer(async (req, res) => { 118 try { 119 const url = new URL(req.url, 'http://localhost:8888'); 120 if (url.pathname !== '/callback') { 121 res.end(); 122 return; 123 } 124 const code = url.searchParams.get('code'); 125 if (!code) { 126 res.end('Missing code'); 127 return; 128 } 129 const tokenRes = await fetch('https://accounts.spotify.com/api/token', { 130 method: 'POST', 131 headers: { 132 'Content-Type': 'application/x-www-form-urlencoded', 133 Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), 134 }, 135 body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI }), 136 }); 137 if (!tokenRes.ok) { 138 const err = await tokenRes.json().catch(() => ({})); 139 server.close(); 140 reject(new CliError('AUTH_FAILED', err?.error_description || `Token exchange failed (${tokenRes.status})`)); 141 return; 142 } 143 const data = await tokenRes.json(); 144 saveTokens({ access_token: data.access_token, refresh_token: data.refresh_token, expires_at: Date.now() + data.expires_in * 1000 }); 145 res.writeHead(200, { 'Content-Type': 'text/html' }); 146 res.end('<h2>Spotify authenticated! You can close this tab.</h2>'); 147 server.close(); 148 resolve([{ status: 'Authenticated successfully' }]); 149 } 150 catch (e) { 151 server.close(); 152 reject(e); 153 } 154 }); 155 server.on('error', (e) => { 156 if (e.code === 'EADDRINUSE') 157 reject(new CliError('PORT_IN_USE', 'Port 8888 is already in use. Stop the other process and retry.')); 158 else 159 reject(e); 160 }); 161 const timeout = setTimeout(() => { server.close(); reject(new CliError('AUTH_TIMEOUT', 'Authentication timed out after 5 minutes')); }, 5 * 60 * 1000); 162 server.listen(8888, () => { 163 const authUrl = `https://accounts.spotify.com/authorize?${new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: REDIRECT_URI, scope: SCOPES })}`; 164 console.log('Opening browser for Spotify login...'); 165 console.log('If it does not open, visit:', authUrl); 166 openBrowser(authUrl); 167 }); 168 server.on('close', () => clearTimeout(timeout)); 169 }); 170 }, 171 }); 172 cli({ 173 site: 'spotify', 174 name: 'status', 175 description: 'Show current playback status', 176 strategy: Strategy.PUBLIC, 177 browser: false, 178 args: [], 179 columns: ['track', 'artist', 'album', 'status', 'progress'], 180 func: async () => { 181 const data = await api('GET', '/me/player'); 182 if (!data || !data.item) 183 return [{ track: 'Nothing playing', artist: '', album: '', status: '', progress: '' }]; 184 const t = data.item; 185 if (t.type !== 'track') 186 return [{ track: t.name, artist: '', album: t.show?.name ?? '', status: data.is_playing ? 'playing' : 'paused', progress: '' }]; 187 const prog = (data.progress_ms ?? 0) / 1000 | 0; 188 const dur = t.duration_ms / 1000 | 0; 189 const fmt = (s) => `${s / 60 | 0}:${String(s % 60).padStart(2, '0')}`; 190 return [{ track: t.name, artist: t.artists.map((a) => a.name).join(', '), album: t.album.name, status: data.is_playing ? 'playing' : 'paused', progress: `${fmt(prog)} / ${fmt(dur)}` }]; 191 }, 192 }); 193 cli({ 194 site: 'spotify', 195 name: 'play', 196 description: 'Resume playback or search and play a track/artist', 197 strategy: Strategy.PUBLIC, 198 browser: false, 199 args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }], 200 columns: ['track', 'artist', 'status'], 201 func: async (_page, kwargs) => { 202 if (kwargs.query) { 203 const { uri, name, artist } = await findTrackUri(kwargs.query); 204 await api('PUT', '/me/player/play', { uris: [uri] }); 205 return [{ track: name, artist, status: 'playing' }]; 206 } 207 await api('PUT', '/me/player/play'); 208 return [{ track: '', artist: '', status: 'resumed' }]; 209 }, 210 }); 211 cli({ 212 site: 'spotify', 213 name: 'pause', 214 description: 'Pause playback', 215 strategy: Strategy.PUBLIC, 216 browser: false, 217 args: [], 218 columns: ['status'], 219 func: async () => { await api('PUT', '/me/player/pause'); return [{ status: 'paused' }]; }, 220 }); 221 cli({ 222 site: 'spotify', 223 name: 'next', 224 description: 'Skip to next track', 225 strategy: Strategy.PUBLIC, 226 browser: false, 227 args: [], 228 columns: ['status'], 229 func: async () => { await api('POST', '/me/player/next'); return [{ status: 'skipped to next' }]; }, 230 }); 231 cli({ 232 site: 'spotify', 233 name: 'prev', 234 description: 'Skip to previous track', 235 strategy: Strategy.PUBLIC, 236 browser: false, 237 args: [], 238 columns: ['status'], 239 func: async () => { await api('POST', '/me/player/previous'); return [{ status: 'skipped to previous' }]; }, 240 }); 241 cli({ 242 site: 'spotify', 243 name: 'volume', 244 description: 'Set playback volume (0-100)', 245 strategy: Strategy.PUBLIC, 246 browser: false, 247 args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }], 248 columns: ['volume'], 249 func: async (_page, kwargs) => { 250 const level = Math.round(kwargs.level); 251 if (level < 0 || level > 100) 252 throw new CliError('INVALID_ARGS', 'Volume must be between 0 and 100'); 253 await api('PUT', `/me/player/volume?volume_percent=${level}`); 254 return [{ volume: `${level}%` }]; 255 }, 256 }); 257 cli({ 258 site: 'spotify', 259 name: 'search', 260 description: 'Search for tracks', 261 strategy: Strategy.PUBLIC, 262 browser: false, 263 args: [ 264 { name: 'query', type: 'str', required: true, positional: true, help: 'Search query' }, 265 { name: 'limit', type: 'int', default: 10, help: 'Number of results (default: 10)' }, 266 ], 267 columns: ['track', 'artist', 'album', 'uri'], 268 func: async (_page, kwargs) => { 269 const limit = Math.min(50, Math.max(1, Math.round(kwargs.limit))); 270 const data = await api('GET', `/search?q=${encodeURIComponent(kwargs.query)}&type=track&limit=${limit}`); 271 const results = mapSpotifyTrackResults(data); 272 if (!results.length) 273 throw new CliError('EMPTY_RESULT', `No results found for: ${kwargs.query}`); 274 return results; 275 }, 276 }); 277 cli({ 278 site: 'spotify', 279 name: 'queue', 280 description: 'Add a track to the playback queue', 281 strategy: Strategy.PUBLIC, 282 browser: false, 283 args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }], 284 columns: ['track', 'artist', 'status'], 285 func: async (_page, kwargs) => { 286 const { uri, name, artist } = await findTrackUri(kwargs.query); 287 await api('POST', `/me/player/queue?uri=${encodeURIComponent(uri)}`); 288 return [{ track: name, artist, status: 'added to queue' }]; 289 }, 290 }); 291 cli({ 292 site: 'spotify', 293 name: 'shuffle', 294 description: 'Toggle shuffle on/off', 295 strategy: Strategy.PUBLIC, 296 browser: false, 297 args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }], 298 columns: ['shuffle'], 299 func: async (_page, kwargs) => { 300 await api('PUT', `/me/player/shuffle?state=${kwargs.state === 'on'}`); 301 return [{ shuffle: kwargs.state }]; 302 }, 303 }); 304 cli({ 305 site: 'spotify', 306 name: 'repeat', 307 description: 'Set repeat mode (off / track / context)', 308 strategy: Strategy.PUBLIC, 309 browser: false, 310 args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }], 311 columns: ['repeat'], 312 func: async (_page, kwargs) => { 313 await api('PUT', `/me/player/repeat?state=${kwargs.mode}`); 314 return [{ repeat: kwargs.mode }]; 315 }, 316 });