/ clis / spotify / spotify.js
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  });