diff --git a/README.md b/README.md index 2355dcef..84a0a9b3 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` | | **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` | +| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | 65+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)** diff --git a/README.zh-CN.md b/README.zh-CN.md index 624760a7..b43bb953 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -173,6 +173,7 @@ npm install -g @jackwener/opencli@latest | **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | 浏览器 | | **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | 浏览器 | | **google** | `news` `search` `suggest` `trends` | 公开 | +| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | OAuth API | | **36kr** | `news` `hot` `search` `article` | 公开 / 浏览器 | | **imdb** | `search` `title` `top` `trending` `person` `reviews` | 公开 | | **producthunt** | `posts` `today` `hot` `browse` | 公开 / 浏览器 | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5b4a58cd..28a8fa60 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -104,6 +104,7 @@ export default defineConfig({ { text: 'Barchart', link: '/adapters/browser/barchart' }, { text: 'Hugging Face', link: '/adapters/browser/hf' }, { text: 'Sina Finance', link: '/adapters/browser/sinafinance' }, + { text: 'Spotify', link: '/adapters/browser/spotify' }, { text: 'Stack Overflow', link: '/adapters/browser/stackoverflow' }, { text: 'Wikipedia', link: '/adapters/browser/wikipedia' }, { text: 'Lobsters', link: '/adapters/browser/lobsters' }, diff --git a/docs/adapters/browser/spotify.md b/docs/adapters/browser/spotify.md new file mode 100644 index 00000000..4682042a --- /dev/null +++ b/docs/adapters/browser/spotify.md @@ -0,0 +1,62 @@ +# Spotify + +**Mode**: 🔑 OAuth API · **Domains**: `accounts.spotify.com`, `api.spotify.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli spotify auth` | Authenticate with Spotify and store tokens locally | +| `opencli spotify status` | Show current playback status | +| `opencli spotify play [query]` | Resume playback or search-and-play a track | +| `opencli spotify pause` | Pause playback | +| `opencli spotify next` | Skip to the next track | +| `opencli spotify prev` | Skip to the previous track | +| `opencli spotify volume <0-100>` | Set playback volume | +| `opencli spotify search ` | Search Spotify tracks | +| `opencli spotify queue ` | Add a track to the playback queue | +| `opencli spotify shuffle ` | Toggle shuffle | +| `opencli spotify repeat ` | Set repeat mode | + +## Usage Examples + +```bash +# First-time setup +opencli spotify auth + +# What is playing right now? +opencli spotify status + +# Resume playback +opencli spotify play + +# Search and immediately play a track +opencli spotify play "Numb Linkin Park" + +# Search without playing +opencli spotify search "Daft Punk" --limit 5 -f json + +# Queue a track +opencli spotify queue "Get Lucky" + +# Playback controls +opencli spotify pause +opencli spotify next +opencli spotify prev +opencli spotify volume 35 +opencli spotify shuffle on +opencli spotify repeat track +``` + +## Setup + +1. Create a Spotify app at +2. Add `http://127.0.0.1:8888/callback` to the app's Redirect URIs +3. Fill in `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` in `~/.opencli/spotify.env` +4. Run `opencli spotify auth` + +## Notes + +- Browser Bridge is not required. +- Tokens are stored locally at `~/.opencli/spotify-tokens.json`. +- Playback commands work best when you already have an active Spotify device/session. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 7a82f177..8804b39a 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -62,6 +62,7 @@ Run `opencli list` for the live registry. | **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public | | **[hf](/adapters/browser/hf)** | `top` | 🌐 Public | | **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public | +| **[spotify](/adapters/browser/spotify)** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` | 🔑 OAuth API | | **[stackoverflow](/adapters/browser/stackoverflow)** | `hot` `search` `bounties` `unanswered` | 🌐 Public | | **[wikipedia](/adapters/browser/wikipedia)** | `search` `summary` `random` `trending` | 🌐 Public | | **[lobsters](/adapters/browser/lobsters)** | `hot` `newest` `active` `tag` | 🌐 Public | diff --git a/scripts/postinstall.js b/scripts/postinstall.js index b852993c..175ecf88 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -196,6 +196,22 @@ function main() { } } + // ── Spotify credentials template ──────────────────────────────────── + const opencliDir = join(home, '.opencli'); + const spotifyEnvFile = join(opencliDir, 'spotify.env'); + ensureDir(opencliDir); + if (!existsSync(spotifyEnvFile)) { + writeFileSync(spotifyEnvFile, + `# Spotify credentials — get them at https://developer.spotify.com/dashboard\n` + + `# Add http://127.0.0.1:8888/callback as a Redirect URI in your Spotify app\n` + + `SPOTIFY_CLIENT_ID=\n` + + `SPOTIFY_CLIENT_SECRET=\n`, + 'utf8' + ); + console.log(`✓ Spotify credentials template created at ${spotifyEnvFile}`); + console.log(` Fill in your Client ID and Secret, then run: opencli spotify auth`); + } + // ── Browser Bridge setup hint ─────────────────────────────────────── console.log(''); console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m'); diff --git a/src/clis/spotify/spotify.ts b/src/clis/spotify/spotify.ts new file mode 100644 index 00000000..528d0541 --- /dev/null +++ b/src/clis/spotify/spotify.ts @@ -0,0 +1,328 @@ +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +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(): Record { + 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'); + +interface Tokens { + access_token: string; + refresh_token: string; + expires_at: number; +} + +function loadTokens(): Tokens | null { + try { return JSON.parse(readFileSync(TOKEN_FILE, 'utf-8')); } catch { return null; } +} + +function saveTokens(tokens: Tokens): void { + mkdirSync(join(homedir(), '.opencli'), { recursive: true }); + writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2)); +} + +async function refreshAccessToken(refreshToken: string): Promise { + 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(() => ({})) as any; + throw new CliError('REFRESH_FAILED', err?.error_description || `Token refresh failed (${res.status})`); + } + const data = await res.json() as any; + const tokens: 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(): Promise { + 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: string, path: string, body?: unknown): Promise { + 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(() => ({})) as any; + throw new CliError('API_ERROR', err?.error?.message || `Spotify API error ${res.status}`); + } + return res.json(); +} + +async function findTrackUri(query: string): Promise<{ uri: string; name: string; artist: string }> { + 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: string): void { + const cmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`; + exec(cmd); +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +cli({ + site: 'spotify', + name: 'auth', + 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(() => ({})) as any; + server.close(); + reject(new CliError('AUTH_FAILED', err?.error_description || `Token exchange failed (${tokenRes.status})`)); + return; + } + const data = await tokenRes.json() as any; + 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('

Spotify authenticated! You can close this tab.

'); + server.close(); + resolve([{ status: 'Authenticated successfully' }]); + } catch (e) { server.close(); reject(e); } + }); + server.on('error', (e: NodeJS.ErrnoException) => { + 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', + 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: number) => `${s / 60 | 0}:${String(s % 60).padStart(2, '0')}`; + return [{ track: t.name, artist: t.artists.map((a: any) => a.name).join(', '), album: t.album.name, status: data.is_playing ? 'playing' : 'paused', progress: `${fmt(prog)} / ${fmt(dur)}` }]; + }, +}); + +cli({ + site: 'spotify', + name: 'play', + 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 (_page, 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', + 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', + 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', + 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', + 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 (_page, 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', + 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 (_page, 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', + 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 (_page, 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', + 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 (_page, kwargs) => { + await api('PUT', `/me/player/shuffle?state=${kwargs.state === 'on'}`); + return [{ shuffle: kwargs.state }]; + }, +}); + +cli({ + site: 'spotify', + name: 'repeat', + 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 (_page, kwargs) => { + await api('PUT', `/me/player/repeat?state=${kwargs.mode}`); + return [{ repeat: kwargs.mode }]; + }, +}); diff --git a/src/clis/spotify/utils.test.ts b/src/clis/spotify/utils.test.ts new file mode 100644 index 00000000..2c96c7c5 --- /dev/null +++ b/src/clis/spotify/utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertSpotifyCredentialsConfigured, + getFirstSpotifyTrack, + hasConfiguredSpotifyCredentials, + mapSpotifyTrackResults, + parseDotEnv, + resolveSpotifyCredentials, +} from './utils.js'; + +describe('spotify utils', () => { + it('parses dotenv-style credential files', () => { + const env = parseDotEnv(` + # Spotify credentials + SPOTIFY_CLIENT_ID=abc123 + SPOTIFY_CLIENT_SECRET=def456 + `); + + expect(env).toEqual({ + SPOTIFY_CLIENT_ID: 'abc123', + SPOTIFY_CLIENT_SECRET: 'def456', + }); + }); + + it('prefers explicit process env over file values', () => { + const credentials = resolveSpotifyCredentials( + { + SPOTIFY_CLIENT_ID: 'file-id', + SPOTIFY_CLIENT_SECRET: 'file-secret', + }, + { + SPOTIFY_CLIENT_ID: 'env-id', + SPOTIFY_CLIENT_SECRET: 'env-secret', + }, + ); + + expect(credentials).toEqual({ + clientId: 'env-id', + clientSecret: 'env-secret', + }); + }); + + it('treats placeholder values as unconfigured credentials', () => { + expect(hasConfiguredSpotifyCredentials({ + clientId: 'your_spotify_client_id_here', + clientSecret: 'your_spotify_client_secret_here', + })).toBe(false); + }); + + it('throws a helpful CONFIG error for empty or placeholder credentials', () => { + expect(() => assertSpotifyCredentialsConfigured({ + clientId: '', + clientSecret: '', + }, '/tmp/spotify.env')).toThrow(/Missing Spotify credentials/); + + expect(() => assertSpotifyCredentialsConfigured({ + clientId: 'your_spotify_client_id_here', + clientSecret: 'real-secret', + }, '/tmp/spotify.env')).toThrow(/Fill in SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET/); + }); + + it('maps search payloads into stable track summaries', () => { + const results = mapSpotifyTrackResults({ + tracks: { + items: [ + { + name: 'Numb', + artists: [{ name: 'Linkin Park' }, { name: 'Jay-Z' }], + album: { name: 'Encore' }, + uri: 'spotify:track:123', + }, + ], + }, + }); + + expect(results).toEqual([ + { + track: 'Numb', + artist: 'Linkin Park, Jay-Z', + album: 'Encore', + uri: 'spotify:track:123', + }, + ]); + expect(getFirstSpotifyTrack({ tracks: { items: [] } })).toBeNull(); + }); +}); diff --git a/src/clis/spotify/utils.ts b/src/clis/spotify/utils.ts new file mode 100644 index 00000000..43464cb5 --- /dev/null +++ b/src/clis/spotify/utils.ts @@ -0,0 +1,92 @@ +import { CliError } from '../../errors.js'; + +export interface SpotifyCredentials { + clientId: string; + clientSecret: string; +} + +export interface SpotifyTrackSummary { + track: string; + artist: string; + album: string; + uri: string; +} + +const SPOTIFY_PLACEHOLDER_PATTERNS = [ + /^your_spotify_client_id_here$/i, + /^your_spotify_client_secret_here$/i, + /^your_.+_here$/i, +]; + +export function parseDotEnv(content: string): Record { + return Object.fromEntries( + content + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#') && line.includes('=')) + .map(line => { + const index = line.indexOf('='); + return [line.slice(0, index).trim(), line.slice(index + 1).trim()] as [string, string]; + }), + ); +} + +export function resolveSpotifyCredentials( + fileEnv: Record, + processEnv: NodeJS.ProcessEnv = process.env, +): SpotifyCredentials { + return { + clientId: processEnv.SPOTIFY_CLIENT_ID || fileEnv.SPOTIFY_CLIENT_ID || '', + clientSecret: processEnv.SPOTIFY_CLIENT_SECRET || fileEnv.SPOTIFY_CLIENT_SECRET || '', + }; +} + +export function isPlaceholderCredential(value: string | null | undefined): boolean { + const normalized = value?.trim() || ''; + if (!normalized) return false; + return SPOTIFY_PLACEHOLDER_PATTERNS.some(pattern => pattern.test(normalized)); +} + +export function hasConfiguredSpotifyCredentials(credentials: SpotifyCredentials): boolean { + return Boolean(credentials.clientId.trim()) && + Boolean(credentials.clientSecret.trim()) && + !isPlaceholderCredential(credentials.clientId) && + !isPlaceholderCredential(credentials.clientSecret); +} + +export function assertSpotifyCredentialsConfigured(credentials: SpotifyCredentials, envFile: string): void { + if (hasConfiguredSpotifyCredentials(credentials)) return; + + throw new CliError( + 'CONFIG', + `Missing Spotify credentials.\n\n` + + `1. Go to https://developer.spotify.com/dashboard and create an app\n` + + `2. Add ${'http://127.0.0.1:8888/callback'} as a Redirect URI\n` + + `3. Copy your Client ID and Client Secret\n` + + `4. Open the file: ${envFile}\n` + + `5. Fill in SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET, then save\n` + + `6. Run: opencli spotify auth`, + ); +} + +export function mapSpotifyTrackResults(data: any): SpotifyTrackSummary[] { + const items = data?.tracks?.items; + if (!Array.isArray(items)) return []; + + return items.map((track: any) => ({ + track: track?.name || '', + artist: Array.isArray(track?.artists) ? track.artists.map((artist: any) => artist.name).join(', ') : '', + album: track?.album?.name || '', + uri: track?.uri || '', + })); +} + +export function getFirstSpotifyTrack(data: any): { uri: string; name: string; artist: string } | null { + const track = mapSpotifyTrackResults(data)[0]; + if (!track) return null; + return { + uri: track.uri, + name: track.track, + artist: track.artist, + }; +}