9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
328 lines
14 KiB
JavaScript
328 lines
14 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { CliError } from '@jackwener/opencli/errors';
|
||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||
import { createServer } from 'http';
|
||
import { homedir } from 'os';
|
||
import { join } from 'path';
|
||
import { exec } from 'child_process';
|
||
import { assertSpotifyCredentialsConfigured, getFirstSpotifyTrack, mapSpotifyTrackResults, parseDotEnv, resolveSpotifyCredentials, } from './utils.js';
|
||
// ── Credentials ───────────────────────────────────────────────────────────────
|
||
// Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET as environment variables,
|
||
// or place them in ~/.opencli/spotify.env:
|
||
// SPOTIFY_CLIENT_ID=your_id
|
||
// SPOTIFY_CLIENT_SECRET=your_secret
|
||
const ENV_FILE = join(homedir(), '.opencli', 'spotify.env');
|
||
function loadEnv() {
|
||
if (!existsSync(ENV_FILE))
|
||
return {};
|
||
return parseDotEnv(readFileSync(ENV_FILE, 'utf-8'));
|
||
}
|
||
const env = loadEnv();
|
||
const credentials = resolveSpotifyCredentials(env);
|
||
const CLIENT_ID = credentials.clientId;
|
||
const CLIENT_SECRET = credentials.clientSecret;
|
||
const REDIRECT_URI = 'http://127.0.0.1:8888/callback';
|
||
const SCOPES = [
|
||
'user-read-playback-state',
|
||
'user-modify-playback-state',
|
||
'user-read-currently-playing',
|
||
'playlist-read-private',
|
||
].join(' ');
|
||
// ── Token storage ─────────────────────────────────────────────────────────────
|
||
const TOKEN_FILE = join(homedir(), '.opencli', 'spotify-tokens.json');
|
||
function loadTokens() {
|
||
try {
|
||
return JSON.parse(readFileSync(TOKEN_FILE, 'utf-8'));
|
||
}
|
||
catch {
|
||
return null;
|
||
}
|
||
}
|
||
function saveTokens(tokens) {
|
||
mkdirSync(join(homedir(), '.opencli'), { recursive: true });
|
||
writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
|
||
}
|
||
async function refreshAccessToken(refreshToken) {
|
||
const res = await fetch('https://accounts.spotify.com/api/token', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'),
|
||
},
|
||
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new CliError('REFRESH_FAILED', err?.error_description || `Token refresh failed (${res.status})`);
|
||
}
|
||
const data = await res.json();
|
||
const tokens = {
|
||
access_token: data.access_token,
|
||
refresh_token: data.refresh_token || refreshToken,
|
||
expires_at: Date.now() + data.expires_in * 1000,
|
||
};
|
||
saveTokens(tokens);
|
||
return tokens.access_token;
|
||
}
|
||
async function getToken() {
|
||
const tokens = loadTokens();
|
||
if (!tokens)
|
||
throw new CliError('AUTH_REQUIRED', 'Not authenticated. Run: opencli spotify auth');
|
||
if (!tokens.access_token || !tokens.refresh_token || !(tokens.expires_at > 0)) {
|
||
throw new CliError('AUTH_CORRUPTED', 'Token file is corrupted. Run: opencli spotify auth');
|
||
}
|
||
if (Date.now() > tokens.expires_at - 60_000)
|
||
return refreshAccessToken(tokens.refresh_token);
|
||
return tokens.access_token;
|
||
}
|
||
// ── Spotify API helper ────────────────────────────────────────────────────────
|
||
async function api(method, path, body) {
|
||
const token = await getToken();
|
||
const res = await fetch(`https://api.spotify.com/v1${path}`, {
|
||
method,
|
||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
if (res.status === 204 || res.status === 202)
|
||
return null;
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new CliError('API_ERROR', err?.error?.message || `Spotify API error ${res.status}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
async function findTrackUri(query) {
|
||
const data = await api('GET', `/search?q=${encodeURIComponent(query)}&type=track&limit=1`);
|
||
const track = getFirstSpotifyTrack(data);
|
||
if (!track)
|
||
throw new CliError('EMPTY_RESULT', `No track found for: ${query}`);
|
||
return track;
|
||
}
|
||
function openBrowser(url) {
|
||
const cmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
|
||
exec(cmd);
|
||
}
|
||
// ── Commands ──────────────────────────────────────────────────────────────────
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'auth',
|
||
access: 'write',
|
||
description: 'Authenticate with Spotify (OAuth — run once)',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [],
|
||
columns: ['status'],
|
||
func: async () => {
|
||
assertSpotifyCredentialsConfigured(credentials, ENV_FILE);
|
||
return new Promise((resolve, reject) => {
|
||
const server = createServer(async (req, res) => {
|
||
try {
|
||
const url = new URL(req.url, 'http://localhost:8888');
|
||
if (url.pathname !== '/callback') {
|
||
res.end();
|
||
return;
|
||
}
|
||
const code = url.searchParams.get('code');
|
||
if (!code) {
|
||
res.end('Missing code');
|
||
return;
|
||
}
|
||
const tokenRes = await fetch('https://accounts.spotify.com/api/token', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'),
|
||
},
|
||
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI }),
|
||
});
|
||
if (!tokenRes.ok) {
|
||
const err = await tokenRes.json().catch(() => ({}));
|
||
server.close();
|
||
reject(new CliError('AUTH_FAILED', err?.error_description || `Token exchange failed (${tokenRes.status})`));
|
||
return;
|
||
}
|
||
const data = await tokenRes.json();
|
||
saveTokens({ access_token: data.access_token, refresh_token: data.refresh_token, expires_at: Date.now() + data.expires_in * 1000 });
|
||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||
res.end('<h2>Spotify authenticated! You can close this tab.</h2>');
|
||
server.close();
|
||
resolve([{ status: 'Authenticated successfully' }]);
|
||
}
|
||
catch (e) {
|
||
server.close();
|
||
reject(e);
|
||
}
|
||
});
|
||
server.on('error', (e) => {
|
||
if (e.code === 'EADDRINUSE')
|
||
reject(new CliError('PORT_IN_USE', 'Port 8888 is already in use. Stop the other process and retry.'));
|
||
else
|
||
reject(e);
|
||
});
|
||
const timeout = setTimeout(() => { server.close(); reject(new CliError('AUTH_TIMEOUT', 'Authentication timed out after 5 minutes')); }, 5 * 60 * 1000);
|
||
server.listen(8888, () => {
|
||
const authUrl = `https://accounts.spotify.com/authorize?${new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: REDIRECT_URI, scope: SCOPES })}`;
|
||
console.log('Opening browser for Spotify login...');
|
||
console.log('If it does not open, visit:', authUrl);
|
||
openBrowser(authUrl);
|
||
});
|
||
server.on('close', () => clearTimeout(timeout));
|
||
});
|
||
},
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'status',
|
||
access: 'read',
|
||
description: 'Show current playback status',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [],
|
||
columns: ['track', 'artist', 'album', 'status', 'progress'],
|
||
func: async () => {
|
||
const data = await api('GET', '/me/player');
|
||
if (!data || !data.item)
|
||
return [{ track: 'Nothing playing', artist: '', album: '', status: '', progress: '' }];
|
||
const t = data.item;
|
||
if (t.type !== 'track')
|
||
return [{ track: t.name, artist: '', album: t.show?.name ?? '', status: data.is_playing ? 'playing' : 'paused', progress: '' }];
|
||
const prog = (data.progress_ms ?? 0) / 1000 | 0;
|
||
const dur = t.duration_ms / 1000 | 0;
|
||
const fmt = (s) => `${s / 60 | 0}:${String(s % 60).padStart(2, '0')}`;
|
||
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)}` }];
|
||
},
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'play',
|
||
access: 'write',
|
||
description: 'Resume playback or search and play a track/artist',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }],
|
||
columns: ['track', 'artist', 'status'],
|
||
func: async (kwargs) => {
|
||
if (kwargs.query) {
|
||
const { uri, name, artist } = await findTrackUri(kwargs.query);
|
||
await api('PUT', '/me/player/play', { uris: [uri] });
|
||
return [{ track: name, artist, status: 'playing' }];
|
||
}
|
||
await api('PUT', '/me/player/play');
|
||
return [{ track: '', artist: '', status: 'resumed' }];
|
||
},
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'pause',
|
||
access: 'write',
|
||
description: 'Pause playback',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [],
|
||
columns: ['status'],
|
||
func: async () => { await api('PUT', '/me/player/pause'); return [{ status: 'paused' }]; },
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'next',
|
||
access: 'write',
|
||
description: 'Skip to next track',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [],
|
||
columns: ['status'],
|
||
func: async () => { await api('POST', '/me/player/next'); return [{ status: 'skipped to next' }]; },
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'prev',
|
||
access: 'write',
|
||
description: 'Skip to previous track',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [],
|
||
columns: ['status'],
|
||
func: async () => { await api('POST', '/me/player/previous'); return [{ status: 'skipped to previous' }]; },
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'volume',
|
||
access: 'write',
|
||
description: 'Set playback volume (0-100)',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }],
|
||
columns: ['volume'],
|
||
func: async (kwargs) => {
|
||
const level = Math.round(kwargs.level);
|
||
if (level < 0 || level > 100)
|
||
throw new CliError('INVALID_ARGS', 'Volume must be between 0 and 100');
|
||
await api('PUT', `/me/player/volume?volume_percent=${level}`);
|
||
return [{ volume: `${level}%` }];
|
||
},
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'search',
|
||
access: 'read',
|
||
description: 'Search for tracks',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [
|
||
{ name: 'query', type: 'str', required: true, positional: true, help: 'Search query' },
|
||
{ name: 'limit', type: 'int', default: 10, help: 'Number of results (default: 10)' },
|
||
],
|
||
columns: ['track', 'artist', 'album', 'uri'],
|
||
func: async (kwargs) => {
|
||
const limit = Math.min(50, Math.max(1, Math.round(kwargs.limit)));
|
||
const data = await api('GET', `/search?q=${encodeURIComponent(kwargs.query)}&type=track&limit=${limit}`);
|
||
const results = mapSpotifyTrackResults(data);
|
||
if (!results.length)
|
||
throw new CliError('EMPTY_RESULT', `No results found for: ${kwargs.query}`);
|
||
return results;
|
||
},
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'queue',
|
||
access: 'write',
|
||
description: 'Add a track to the playback queue',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }],
|
||
columns: ['track', 'artist', 'status'],
|
||
func: async (kwargs) => {
|
||
const { uri, name, artist } = await findTrackUri(kwargs.query);
|
||
await api('POST', `/me/player/queue?uri=${encodeURIComponent(uri)}`);
|
||
return [{ track: name, artist, status: 'added to queue' }];
|
||
},
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'shuffle',
|
||
access: 'write',
|
||
description: 'Toggle shuffle on/off',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }],
|
||
columns: ['shuffle'],
|
||
func: async (kwargs) => {
|
||
await api('PUT', `/me/player/shuffle?state=${kwargs.state === 'on'}`);
|
||
return [{ shuffle: kwargs.state }];
|
||
},
|
||
});
|
||
cli({
|
||
site: 'spotify',
|
||
name: 'repeat',
|
||
access: 'write',
|
||
description: 'Set repeat mode (off / track / context)',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: false,
|
||
args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }],
|
||
columns: ['repeat'],
|
||
func: async (kwargs) => {
|
||
await api('PUT', `/me/player/repeat?state=${kwargs.mode}`);
|
||
return [{ repeat: kwargs.mode }];
|
||
},
|
||
});
|