@bzenky/spoti 0.3.0 → 0.5.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.
Files changed (47) hide show
  1. package/README.md +68 -6
  2. package/dist/app.d.ts +10 -2
  3. package/dist/app.js +207 -82
  4. package/dist/app.js.map +1 -1
  5. package/dist/cli.js +4 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/services/device.service.d.ts +3 -3
  8. package/dist/services/device.service.js +19 -11
  9. package/dist/services/device.service.js.map +1 -1
  10. package/dist/services/player.service.d.ts +4 -2
  11. package/dist/services/player.service.js +35 -5
  12. package/dist/services/player.service.js.map +1 -1
  13. package/dist/services/queue.service.js +11 -6
  14. package/dist/services/queue.service.js.map +1 -1
  15. package/dist/services/search.service.d.ts +4 -4
  16. package/dist/services/search.service.js +10 -9
  17. package/dist/services/search.service.js.map +1 -1
  18. package/dist/spotify/client.d.ts +2 -0
  19. package/dist/spotify/client.js +170 -46
  20. package/dist/spotify/client.js.map +1 -1
  21. package/dist/spotify/types.d.ts +2 -0
  22. package/dist/storage/config.d.ts +3 -0
  23. package/dist/storage/config.js +3 -0
  24. package/dist/storage/config.js.map +1 -1
  25. package/dist/ui/completions.d.ts +3 -0
  26. package/dist/ui/completions.js +237 -0
  27. package/dist/ui/completions.js.map +1 -0
  28. package/dist/ui/interactive-search.d.ts +36 -0
  29. package/dist/ui/interactive-search.js +337 -0
  30. package/dist/ui/interactive-search.js.map +1 -0
  31. package/dist/ui/output.d.ts +27 -11
  32. package/dist/ui/output.js +87 -45
  33. package/dist/ui/output.js.map +1 -1
  34. package/dist/ui/progress.d.ts +6 -0
  35. package/dist/ui/progress.js +25 -0
  36. package/dist/ui/progress.js.map +1 -0
  37. package/dist/ui/prompts.d.ts +11 -1
  38. package/dist/ui/prompts.js +65 -13
  39. package/dist/ui/prompts.js.map +1 -1
  40. package/dist/ui/watch.js +3 -2
  41. package/dist/ui/watch.js.map +1 -1
  42. package/dist/utils/errors.js +2 -1
  43. package/dist/utils/errors.js.map +1 -1
  44. package/dist/utils/text.d.ts +2 -0
  45. package/dist/utils/text.js +10 -0
  46. package/dist/utils/text.js.map +1 -0
  47. package/package.json +2 -1
package/dist/app.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { Command } from 'commander';
2
2
  import { UPDATE_COMMAND, } from './services/update.service.js';
3
3
  import { CONFIG_KEYS, parseConfigValue, } from './storage/config.js';
4
- import { formatAlbumDetail, formatArtistDetail, formatPlayback, formatPlaylist, formatPlaylistOverview, formatRecentlyPlayed, formatSavedTrack, formatTrack, } from './ui/output.js';
5
- import { confirmUpdate, promptSpotifyClientId, selectAlbum, selectAlbumAction, selectArtist, selectArtistAction, selectPlaylist, selectPlaylistAction, selectTrack, } from './ui/prompts.js';
4
+ import { formatAlbumDetail, formatArtistDetail, formatPlayback, formatPlaylist, formatPlaylistOverview, formatRecentlyPlayed, formatSavedTrack, formatTrack, plainOutputStyles, sanitizeOneLineText, } from './ui/output.js';
5
+ import { confirmUpdate, promptSpotifyClientId, selectAlbum, selectAlbumAction, selectArtist, selectArtistAction, selectLikedTrack, selectListedPlaylist, selectPlaylist, selectPlaylistAction, selectRecentTrack, selectTrack, } from './ui/prompts.js';
6
+ import { generateCompletionScript } from './ui/completions.js';
7
+ import { withProgress } from './ui/progress.js';
8
+ import { runInteractiveSearch, } from './ui/interactive-search.js';
6
9
  import { watchPlayback } from './ui/watch.js';
7
10
  import { ConfigurationError } from './utils/errors.js';
8
11
  import { formatDuration } from './utils/time.js';
@@ -13,19 +16,33 @@ export function createProgram(dependencies) {
13
16
  const chooseAlbum = dependencies.chooseAlbum ?? selectAlbum;
14
17
  const chooseArtist = dependencies.chooseArtist ?? selectArtist;
15
18
  const choosePlaylist = dependencies.choosePlaylist ?? selectPlaylist;
19
+ const chooseListedPlaylist = dependencies.chooseListedPlaylist ?? selectListedPlaylist;
16
20
  const chooseAlbumAction = dependencies.chooseAlbumAction ?? selectAlbumAction;
17
21
  const chooseArtistAction = dependencies.chooseArtistAction ?? selectArtistAction;
18
22
  const choosePlaylistAction = dependencies.choosePlaylistAction ?? selectPlaylistAction;
23
+ const chooseLikedTrack = dependencies.chooseLikedTrack ?? selectLikedTrack;
24
+ const chooseRecentTrack = dependencies.chooseRecentTrack ?? selectRecentTrack;
19
25
  const requestClientId = dependencies.requestSpotifyClientId ?? promptSpotifyClientId;
20
26
  const requestUpdateConfirmation = dependencies.confirmUpdate ?? confirmUpdate;
27
+ const styles = dependencies.styles ?? plainOutputStyles;
28
+ const safe = sanitizeOneLineText;
29
+ const safeArtists = (artists) => artists.map(safe).join(', ');
30
+ const startInteractiveSearch = dependencies.interactiveSearch ??
31
+ (() => runInteractiveSearch({
32
+ search: dependencies.search,
33
+ player: dependencies.player,
34
+ styles,
35
+ }));
36
+ const showProgress = dependencies.progress ?? withProgress;
37
+ const runTask = (label, task) => showProgress(label, task);
21
38
  const startWatching = dependencies.watchPlayback ?? watchPlayback;
22
39
  const runAlbumAction = async (album) => {
23
- const detail = await dependencies.catalog.getAlbum(album.id);
24
- dependencies.output.log(formatAlbumDetail(detail));
40
+ const detail = await runTask('Loading album…', () => dependencies.catalog.getAlbum(album.id));
41
+ dependencies.output.log(formatAlbumDetail(detail, styles));
25
42
  const action = await chooseAlbumAction();
26
43
  if (action === 'play-album') {
27
44
  await dependencies.player.playContext(detail.uri);
28
- dependencies.output.log(`▶ Playing album ${detail.name}`);
45
+ dependencies.output.log(`▶ Playing album ${safe(detail.name)}`);
29
46
  return;
30
47
  }
31
48
  if (action === 'play-track') {
@@ -33,13 +50,33 @@ export function createProgram(dependencies) {
33
50
  if (!track)
34
51
  return;
35
52
  await dependencies.player.playTrack(track.uri);
36
- dependencies.output.log(`▶ Playing ${track.name} — ${track.artists.join(', ')}`);
53
+ dependencies.output.log(`▶ Playing ${safe(track.name)} — ${safeArtists(track.artists)}`);
37
54
  }
38
55
  };
39
56
  program
40
57
  .name('spoti')
41
58
  .description('Control Spotify from your terminal')
42
- .version(VERSION);
59
+ .version(VERSION)
60
+ .allowExcessArguments()
61
+ .action(() => {
62
+ const unknownCommand = program.args[0];
63
+ if (unknownCommand) {
64
+ throw new ConfigurationError(`Unknown command "${sanitizeOneLineText(unknownCommand)}".\n\nRun: spoti --help`);
65
+ }
66
+ program.outputHelp();
67
+ });
68
+ const interactiveCommand = program
69
+ .command('interactive')
70
+ .alias('i')
71
+ .description('Open the keyboard-driven Spotify search')
72
+ .action(async () => {
73
+ const result = await startInteractiveSearch();
74
+ if (result.status === 'not-interactive')
75
+ interactiveCommand.outputHelp();
76
+ else if (result.status === 'played') {
77
+ dependencies.output.log(`▶ Playing ${styles.name(safe(result.label))}`);
78
+ }
79
+ });
43
80
  program
44
81
  .command('setup')
45
82
  .description('Save your Spotify application client ID')
@@ -59,7 +96,7 @@ export function createProgram(dependencies) {
59
96
  .action(async () => {
60
97
  dependencies.output.log('Opening Spotify authorization in your browser…');
61
98
  const user = await dependencies.auth.login();
62
- dependencies.output.log(`✓ Logged in as ${user.displayName}`);
99
+ dependencies.output.log(`✓ Logged in as ${safe(user.displayName)}`);
63
100
  });
64
101
  program
65
102
  .command('logout')
@@ -77,13 +114,14 @@ export function createProgram(dependencies) {
77
114
  return;
78
115
  }
79
116
  const user = await dependencies.auth.getCurrentUser();
80
- dependencies.output.log(`✓ Logged in as ${user.displayName}`);
117
+ dependencies.output.log(`✓ Logged in as ${safe(user.displayName)}`);
81
118
  });
82
119
  const configCommand = program
83
120
  .command('config')
84
121
  .description('View and update spoti configuration')
85
122
  .action(async () => {
86
- dependencies.output.log(formatConfig(await dependencies.config.read()));
123
+ const config = await dependencies.config.read();
124
+ dependencies.output.log(formatConfig(config));
87
125
  });
88
126
  configCommand
89
127
  .command('get')
@@ -105,6 +143,24 @@ export function createProgram(dependencies) {
105
143
  await dependencies.config.set(key, value);
106
144
  dependencies.output.log(`✓ ${key} = ${String(value)}`);
107
145
  });
146
+ configCommand
147
+ .command('unset')
148
+ .description('Reset one configuration value to its default')
149
+ .argument('<key>', 'configuration key')
150
+ .action(async (keyName) => {
151
+ const key = parseConfigKey(keyName);
152
+ await dependencies.config.resetKey(key);
153
+ dependencies.output.log(`✓ ${key} reset`);
154
+ });
155
+ configCommand
156
+ .command('path')
157
+ .description('Print the configuration file path')
158
+ .action(() => {
159
+ if (!dependencies.config.path) {
160
+ throw new ConfigurationError('The active configuration store has no file path.');
161
+ }
162
+ dependencies.output.log(dependencies.config.path);
163
+ });
108
164
  configCommand
109
165
  .command('reset')
110
166
  .description('Reset configuration to defaults')
@@ -114,6 +170,7 @@ export function createProgram(dependencies) {
114
170
  });
115
171
  program
116
172
  .command('now')
173
+ .alias('np')
117
174
  .description('Show the current Spotify playback')
118
175
  .option('-w, --watch', 'continuously refresh playback information')
119
176
  .action(async (options) => {
@@ -126,10 +183,11 @@ export function createProgram(dependencies) {
126
183
  return;
127
184
  }
128
185
  const playback = await dependencies.player.getCurrentPlayback();
129
- dependencies.output.log(playback ? formatPlayback(playback) : 'Nothing is currently playing.');
186
+ dependencies.output.log(playback ? formatPlayback(playback, styles) : 'Nothing is currently playing.');
130
187
  });
131
188
  program
132
189
  .command('pause')
190
+ .alias('pa')
133
191
  .description('Pause playback')
134
192
  .action(async () => {
135
193
  await dependencies.player.pause();
@@ -137,6 +195,7 @@ export function createProgram(dependencies) {
137
195
  });
138
196
  program
139
197
  .command('resume')
198
+ .alias('r')
140
199
  .description('Resume playback')
141
200
  .action(async () => {
142
201
  await dependencies.player.resume();
@@ -160,27 +219,30 @@ export function createProgram(dependencies) {
160
219
  });
161
220
  program
162
221
  .command('devices')
222
+ .alias('devs')
163
223
  .description('List available Spotify Connect devices')
164
224
  .action(async () => {
165
- const devices = await dependencies.device.getDevices();
166
- if (devices.length === 0) {
167
- dependencies.output.log('No Spotify devices are available. Open Spotify on a device and try again.');
168
- return;
169
- }
170
- dependencies.output.log(devices
171
- .map((device, index) => {
172
- const state = device.isActive
173
- ? 'active'
174
- : device.isRestricted
175
- ? 'restricted'
176
- : 'available';
177
- const volume = device.volumePercent === null ? '' : ` · ${device.volumePercent}%`;
178
- return `${index + 1}. ${device.name} · ${device.type} · ${state}${volume}`;
179
- })
180
- .join('\n'));
225
+ const devices = await runTask('Loading devices…', () => dependencies.device.getDevices());
226
+ const formatted = devices.length === 0
227
+ ? 'No Spotify devices are available. Open Spotify on a device and try again.'
228
+ : devices
229
+ .map((device, index) => {
230
+ const state = device.isActive
231
+ ? 'active'
232
+ : device.isRestricted
233
+ ? 'restricted'
234
+ : 'available';
235
+ const volume = device.volumePercent === null ? '' : ` · ${device.volumePercent}%`;
236
+ const name = styles.name(sanitizeOneLineText(device.name));
237
+ const metadata = styles.metadata(`${sanitizeOneLineText(device.type)} · ${state}${volume}`);
238
+ return `${index + 1}. ${name} · ${metadata}`;
239
+ })
240
+ .join('\n');
241
+ dependencies.output.log(formatted);
181
242
  });
182
243
  program
183
244
  .command('device')
245
+ .alias('dev')
184
246
  .description('Transfer playback to a Spotify Connect device')
185
247
  .argument('<number-name-or-id...>', 'displayed number, exact device name, or ID')
186
248
  .action(async (nameOrIdParts) => {
@@ -188,7 +250,7 @@ export function createProgram(dependencies) {
188
250
  if (!device.id)
189
251
  throw new ConfigurationError('The selected device has no usable ID.');
190
252
  await dependencies.device.transferPlayback(device.id);
191
- dependencies.output.log(`✓ Active device: ${device.name}`);
253
+ dependencies.output.log(`✓ Active device: ${safe(device.name)}`);
192
254
  });
193
255
  program
194
256
  .command('seek')
@@ -204,6 +266,7 @@ export function createProgram(dependencies) {
204
266
  });
205
267
  program
206
268
  .command('volume')
269
+ .alias('vol')
207
270
  .description('Set or adjust the active device volume')
208
271
  .argument('<value>', 'volume from 0-100, or a relative change such as +10 or -10')
209
272
  .allowUnknownOption()
@@ -216,31 +279,32 @@ export function createProgram(dependencies) {
216
279
  });
217
280
  program
218
281
  .command('queue')
282
+ .alias('q')
219
283
  .description('Show the playback queue or add a searched track')
220
284
  .argument('[query...]', 'track name to add')
221
285
  .option('--first', 'queue the first search result without prompting')
222
286
  .action(async (queryParts, options) => {
223
287
  const query = queryParts.join(' ').trim();
224
288
  if (!query) {
225
- const playbackQueue = await dependencies.queue.getQueue();
289
+ const playbackQueue = await runTask('Loading queue…', () => dependencies.queue.getQueue());
226
290
  const lines = playbackQueue.currentlyPlaying
227
291
  ? [
228
- `Now: ${formatQueueItem(playbackQueue.currentlyPlaying)}`,
292
+ `${styles.heading('Now:')} ${formatQueueItem(playbackQueue.currentlyPlaying, styles)}`,
229
293
  '',
230
- 'Up next:',
294
+ styles.heading('Up next:'),
231
295
  ]
232
- : ['Up next:'];
296
+ : [styles.heading('Up next:')];
233
297
  if (playbackQueue.queue.length === 0)
234
298
  lines.push('Queue is empty.');
235
299
  else {
236
- lines.push(...playbackQueue.queue.map((item, index) => `${index + 1}. ${formatQueueItem(item)}`));
300
+ lines.push(...playbackQueue.queue.map((item, index) => `${index + 1}. ${formatQueueItem(item, styles)}`));
237
301
  }
238
302
  dependencies.output.log(lines.join('\n'));
239
303
  return;
240
304
  }
241
305
  const tracks = await dependencies.search.searchTracks(query);
242
306
  if (tracks.length === 0) {
243
- dependencies.output.log(`No tracks found for "${query}".`);
307
+ dependencies.output.log(`No tracks found for "${safe(query)}".`);
244
308
  return;
245
309
  }
246
310
  const track = options.first ? tracks[0] : await chooseTrack(tracks);
@@ -249,13 +313,18 @@ export function createProgram(dependencies) {
249
313
  return;
250
314
  }
251
315
  await dependencies.queue.addItem(track.uri);
252
- dependencies.output.log(`✓ Queued ${track.name} — ${track.artists.join(', ')}`);
316
+ dependencies.output.log(`✓ Queued ${safe(track.name)} — ${safeArtists(track.artists)}`);
253
317
  });
254
318
  program
255
319
  .command('shuffle')
256
- .description('Turn playback shuffle on or off')
257
- .argument('<state>', 'on or off')
320
+ .description('Show or change the playback shuffle state')
321
+ .argument('[state]', 'on or off')
258
322
  .action(async (state) => {
323
+ if (state === undefined) {
324
+ const enabled = await dependencies.player.getShuffleState();
325
+ dependencies.output.log(`🔀 Shuffle: ${enabled ? 'on' : 'off'}`);
326
+ return;
327
+ }
259
328
  if (state !== 'on' && state !== 'off') {
260
329
  throw new ConfigurationError('Shuffle state must be either "on" or "off".');
261
330
  }
@@ -264,9 +333,15 @@ export function createProgram(dependencies) {
264
333
  });
265
334
  program
266
335
  .command('repeat')
267
- .description('Set the playback repeat mode')
268
- .argument('<mode>', 'off, track, or context')
336
+ .alias('rep')
337
+ .description('Show or change the playback repeat mode')
338
+ .argument('[mode]', 'off, track, or context')
269
339
  .action(async (mode) => {
340
+ if (mode === undefined) {
341
+ const currentMode = await dependencies.player.getRepeatState();
342
+ dependencies.output.log(`🔁 Repeat: ${currentMode}`);
343
+ return;
344
+ }
270
345
  if (mode !== 'off' && mode !== 'track' && mode !== 'context') {
271
346
  throw new ConfigurationError('Repeat mode must be "off", "track", or "context".');
272
347
  }
@@ -280,9 +355,9 @@ export function createProgram(dependencies) {
280
355
  .option('--first', 'select the first result without prompting')
281
356
  .action(async (queryParts, options) => {
282
357
  const query = queryParts.join(' ').trim();
283
- const albums = await dependencies.search.searchAlbums(query);
358
+ const albums = await runTask('Searching albums…', () => dependencies.search.searchAlbums(query));
284
359
  if (albums.length === 0) {
285
- dependencies.output.log(`No albums found for "${query}".`);
360
+ dependencies.output.log(`No albums found for "${safe(query)}".`);
286
361
  return;
287
362
  }
288
363
  const album = options.first ? albums[0] : await chooseAlbum(albums);
@@ -299,9 +374,9 @@ export function createProgram(dependencies) {
299
374
  .option('--first', 'select the first result without prompting')
300
375
  .action(async (queryParts, options) => {
301
376
  const query = queryParts.join(' ').trim();
302
- const artists = await dependencies.search.searchArtists(query);
377
+ const artists = await runTask('Searching artists…', () => dependencies.search.searchArtists(query));
303
378
  if (artists.length === 0) {
304
- dependencies.output.log(`No artists found for "${query}".`);
379
+ dependencies.output.log(`No artists found for "${safe(query)}".`);
305
380
  return;
306
381
  }
307
382
  const artist = options.first ? artists[0] : await chooseArtist(artists);
@@ -309,18 +384,18 @@ export function createProgram(dependencies) {
309
384
  dependencies.output.log('Selection cancelled.');
310
385
  return;
311
386
  }
312
- const detail = await dependencies.catalog.getArtist(artist.id);
313
- dependencies.output.log(formatArtistDetail(detail));
387
+ const detail = await runTask('Loading artist…', () => dependencies.catalog.getArtist(artist.id));
388
+ dependencies.output.log(formatArtistDetail(detail, styles));
314
389
  const action = await chooseArtistAction();
315
390
  if (action === 'play-artist') {
316
391
  await dependencies.player.playContext(detail.uri);
317
- dependencies.output.log(`▶ Playing artist ${detail.name}`);
392
+ dependencies.output.log(`▶ Playing artist ${safe(detail.name)}`);
318
393
  return;
319
394
  }
320
395
  if (action === 'select-album') {
321
- const albums = await dependencies.catalog.getArtistAlbums(detail.id);
396
+ const albums = await runTask('Loading artist albums…', () => dependencies.catalog.getArtistAlbums(detail.id));
322
397
  if (albums.length === 0) {
323
- dependencies.output.log(`No albums found for ${detail.name}.`);
398
+ dependencies.output.log(`No albums found for ${safe(detail.name)}.`);
324
399
  return;
325
400
  }
326
401
  const album = await chooseAlbum(albums);
@@ -330,22 +405,33 @@ export function createProgram(dependencies) {
330
405
  });
331
406
  program
332
407
  .command('playlists')
333
- .description('List your Spotify playlists')
408
+ .alias('pls')
409
+ .description('List and optionally play your Spotify playlists')
334
410
  .option('-l, --limit <number>', 'maximum number of playlists', parseCollectionLimit, 50)
335
411
  .action(async (options) => {
336
- const playlists = await dependencies.playlist.listPlaylists(options.limit);
337
- dependencies.output.log(playlists.length === 0
338
- ? 'No playlists found.'
339
- : playlists.map((playlist, index) => formatPlaylist(playlist, index)).join('\n'));
412
+ const playlists = await runTask('Loading playlists…', () => dependencies.playlist.listPlaylists(options.limit));
413
+ if (playlists.length === 0) {
414
+ dependencies.output.log('No playlists found.');
415
+ return;
416
+ }
417
+ dependencies.output.log(playlists
418
+ .map((playlist, index) => formatPlaylist(playlist, index, styles))
419
+ .join('\n'));
420
+ const selected = await chooseListedPlaylist(playlists);
421
+ if (!selected)
422
+ return;
423
+ await dependencies.player.playContext(selected.uri);
424
+ dependencies.output.log(`▶ Playing playlist ${safe(selected.name)}`);
340
425
  });
341
426
  program
342
427
  .command('playlist')
428
+ .alias('pl')
343
429
  .description('Show one of your Spotify playlists')
344
430
  .argument('<query...>', 'playlist name')
345
431
  .option('--first', 'select the first match without prompting')
346
432
  .action(async (queryParts, options) => {
347
433
  const query = queryParts.join(' ').trim();
348
- const allPlaylists = await dependencies.playlist.listPlaylists(50);
434
+ const allPlaylists = await runTask('Loading playlists…', () => dependencies.playlist.listPlaylists(50));
349
435
  let playlist;
350
436
  if (/^\d+$/.test(query)) {
351
437
  playlist = findNumberedPlaylist(allPlaylists, query);
@@ -353,7 +439,7 @@ export function createProgram(dependencies) {
353
439
  else {
354
440
  const matches = allPlaylists.filter((playlist) => playlist.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
355
441
  if (matches.length === 0) {
356
- dependencies.output.log(`No playlists found for "${query}".`);
442
+ dependencies.output.log(`No playlists found for "${safe(query)}".`);
357
443
  return;
358
444
  }
359
445
  playlist = options.first ? matches[0] : await choosePlaylist(matches);
@@ -362,21 +448,28 @@ export function createProgram(dependencies) {
362
448
  dependencies.output.log('Selection cancelled.');
363
449
  return;
364
450
  }
365
- dependencies.output.log(formatPlaylistOverview(playlist));
451
+ dependencies.output.log(formatPlaylistOverview(playlist, styles));
366
452
  if ((await choosePlaylistAction()) === 'play-playlist') {
367
453
  await dependencies.player.playContext(playlist.uri);
368
- dependencies.output.log(`▶ Playing playlist ${playlist.name}`);
454
+ dependencies.output.log(`▶ Playing playlist ${safe(playlist.name)}`);
369
455
  }
370
456
  });
371
457
  program
372
458
  .command('liked')
373
- .description('List your liked tracks')
459
+ .description('List and optionally play your liked tracks')
374
460
  .option('-l, --limit <number>', 'maximum number of tracks', parseCollectionLimit, 20)
375
461
  .action(async (options) => {
376
- const tracks = await dependencies.library.getLikedTracks(options.limit);
377
- dependencies.output.log(tracks.length === 0
378
- ? 'No liked tracks found.'
379
- : tracks.map((item, index) => formatSavedTrack(item, index)).join('\n'));
462
+ const tracks = await runTask('Loading liked tracks…', () => dependencies.library.getLikedTracks(options.limit));
463
+ if (tracks.length === 0) {
464
+ dependencies.output.log('No liked tracks found.');
465
+ return;
466
+ }
467
+ dependencies.output.log(tracks.map((item, index) => formatSavedTrack(item, index, styles)).join('\n'));
468
+ const selected = await chooseLikedTrack(tracks);
469
+ if (!selected)
470
+ return;
471
+ await dependencies.player.playTrack(selected.uri);
472
+ dependencies.output.log(`▶ Playing ${safe(selected.name)} — ${safeArtists(selected.artists)}`);
380
473
  });
381
474
  program
382
475
  .command('like')
@@ -384,7 +477,7 @@ export function createProgram(dependencies) {
384
477
  .action(async () => {
385
478
  const track = await dependencies.library.likeCurrentTrack();
386
479
  dependencies.output.log(track
387
- ? `♥ Liked ${track.name} — ${track.artists.join(', ')}`
480
+ ? `♥ Liked ${safe(track.name)} — ${safeArtists(track.artists)}`
388
481
  : 'No playable track is currently selected.');
389
482
  });
390
483
  program
@@ -393,18 +486,26 @@ export function createProgram(dependencies) {
393
486
  .action(async () => {
394
487
  const track = await dependencies.library.unlikeCurrentTrack();
395
488
  dependencies.output.log(track
396
- ? `♡ Unliked ${track.name} — ${track.artists.join(', ')}`
489
+ ? `♡ Unliked ${safe(track.name)} — ${safeArtists(track.artists)}`
397
490
  : 'No playable track is currently selected.');
398
491
  });
399
492
  program
400
493
  .command('recent')
401
- .description('Show recently played tracks')
494
+ .alias('rec')
495
+ .description('Show and optionally play recently played tracks')
402
496
  .option('-l, --limit <number>', 'maximum number of tracks', parseCollectionLimit, 20)
403
497
  .action(async (options) => {
404
- const tracks = await dependencies.recent.getRecentlyPlayed(options.limit);
405
- dependencies.output.log(tracks.length === 0
406
- ? 'No recently played tracks found.'
407
- : tracks.map((item, index) => formatRecentlyPlayed(item, index)).join('\n'));
498
+ const tracks = await runTask('Loading recent tracks…', () => dependencies.recent.getRecentlyPlayed(options.limit));
499
+ if (tracks.length === 0) {
500
+ dependencies.output.log('No recently played tracks found.');
501
+ return;
502
+ }
503
+ dependencies.output.log(tracks.map((item, index) => formatRecentlyPlayed(item, index, styles)).join('\n'));
504
+ const selected = await chooseRecentTrack(tracks);
505
+ if (!selected)
506
+ return;
507
+ await dependencies.player.playTrack(selected.uri);
508
+ dependencies.output.log(`▶ Playing ${safe(selected.name)} — ${safeArtists(selected.artists)}`);
408
509
  });
409
510
  program
410
511
  .command('update')
@@ -429,22 +530,31 @@ export function createProgram(dependencies) {
429
530
  await dependencies.update.installLatest(true, result.latestVersion);
430
531
  dependencies.output.log(`✓ Updated spoti to ${result.latestVersion}.`);
431
532
  });
533
+ program
534
+ .command('completion')
535
+ .description('Generate a shell completion script')
536
+ .argument('<shell>', 'bash, zsh, or fish')
537
+ .action((shellName) => {
538
+ dependencies.output.log(generateCompletionScript(parseCompletionShell(shellName)));
539
+ });
432
540
  program
433
541
  .command('search')
542
+ .alias('s')
434
543
  .description('Search Spotify tracks')
435
544
  .argument('<query...>', 'track name to search for')
436
545
  .option('-l, --limit <number>', 'maximum number of results', parseLimit, 10)
437
546
  .action(async (queryParts, options) => {
438
547
  const query = queryParts.join(' ');
439
- const tracks = await dependencies.search.searchTracks(query, options.limit);
548
+ const tracks = await runTask('Searching tracks…', () => dependencies.search.searchTracks(query, options.limit));
440
549
  if (tracks.length === 0) {
441
- dependencies.output.log(`No tracks found for "${query}".`);
550
+ dependencies.output.log(`No tracks found for "${safe(query)}".`);
442
551
  return;
443
552
  }
444
- dependencies.output.log(tracks.map((track, index) => formatTrack(track, index)).join('\n'));
553
+ dependencies.output.log(tracks.map((track, index) => formatTrack(track, index, styles)).join('\n'));
445
554
  });
446
555
  program
447
556
  .command('play')
557
+ .alias('p')
448
558
  .description('Play a track or an explicit album, artist, or playlist context')
449
559
  .argument('[query...]', 'track query, or: track|album|artist|playlist <query>')
450
560
  .option('--first', 'play the first search result without prompting')
@@ -487,7 +597,7 @@ export function createProgram(dependencies) {
487
597
  await dependencies.player.playTrack(selection.uri);
488
598
  else
489
599
  await dependencies.player.playContext(selection.uri);
490
- dependencies.output.log(`▶ Playing ${selection.label}`);
600
+ dependencies.output.log(`▶ Playing ${safe(selection.label)}`);
491
601
  if (shouldWatch) {
492
602
  await startWatching({
493
603
  player: dependencies.player,
@@ -502,20 +612,23 @@ async function selectPlaybackItem(options) {
502
612
  let selected;
503
613
  if (options.type === 'track') {
504
614
  const items = await dependencies.search.searchTracks(query);
505
- if (items.length === 0)
615
+ if (items.length === 0) {
506
616
  return reportNoResults(dependencies.output, 'tracks', query);
617
+ }
507
618
  selected = first ? items[0] : await options.chooseTrack(items);
508
619
  }
509
620
  else if (options.type === 'album') {
510
621
  const items = await dependencies.search.searchAlbums(query);
511
- if (items.length === 0)
622
+ if (items.length === 0) {
512
623
  return reportNoResults(dependencies.output, 'albums', query);
624
+ }
513
625
  selected = first ? items[0] : await options.chooseAlbum(items);
514
626
  }
515
627
  else if (options.type === 'artist') {
516
628
  const items = await dependencies.search.searchArtists(query);
517
- if (items.length === 0)
629
+ if (items.length === 0) {
518
630
  return reportNoResults(dependencies.output, 'artists', query);
631
+ }
519
632
  selected = first ? items[0] : await options.chooseArtist(items);
520
633
  }
521
634
  else {
@@ -524,8 +637,9 @@ async function selectPlaybackItem(options) {
524
637
  }
525
638
  else {
526
639
  const items = await dependencies.search.searchPlaylists(query);
527
- if (items.length === 0)
640
+ if (items.length === 0) {
528
641
  return reportNoResults(dependencies.output, 'playlists', query);
642
+ }
529
643
  selected = first ? items[0] : await options.choosePlaylist(items);
530
644
  }
531
645
  }
@@ -538,10 +652,14 @@ async function selectPlaybackItem(options) {
538
652
  return {
539
653
  type: 'track',
540
654
  uri: track.uri,
541
- label: `${track.name} — ${track.artists.join(', ')}`,
655
+ label: `${sanitizeOneLineText(track.name)} — ${track.artists.map(sanitizeOneLineText).join(', ')}`,
542
656
  };
543
657
  }
544
- return { type: options.type, uri: selected.uri, label: selected.name };
658
+ return {
659
+ type: options.type,
660
+ uri: selected.uri,
661
+ label: sanitizeOneLineText(selected.name),
662
+ };
545
663
  }
546
664
  function findNumberedPlaylist(playlists, number) {
547
665
  const index = Number(number) - 1;
@@ -552,7 +670,7 @@ function findNumberedPlaylist(playlists, number) {
552
670
  return playlist;
553
671
  }
554
672
  function reportNoResults(output, type, query) {
555
- output.log(`No ${type} found for "${query}".`);
673
+ output.log(`No ${type} found for "${sanitizeOneLineText(query)}".`);
556
674
  return null;
557
675
  }
558
676
  function isSpotifyPlayableType(value) {
@@ -576,8 +694,10 @@ function parseSeekInput(value) {
576
694
  milliseconds: sign * (minutes * 60 + seconds) * 1_000,
577
695
  };
578
696
  }
579
- function formatQueueItem(item) {
580
- return `${item.name} ${item.subtitle} · ${formatDuration(item.durationMs)}`;
697
+ function formatQueueItem(item, styles) {
698
+ const name = styles.name(sanitizeOneLineText(item.name));
699
+ const metadata = styles.metadata(`${sanitizeOneLineText(item.subtitle)} · ${formatDuration(item.durationMs)}`);
700
+ return `${name} — ${metadata}`;
581
701
  }
582
702
  function parseVolumeInput(value) {
583
703
  if (/^[+-]\d+$/.test(value)) {
@@ -603,6 +723,11 @@ function parseConfigKey(value) {
603
723
  function formatConfig(config) {
604
724
  return CONFIG_KEYS.map((key) => `${key}: ${String(config[key])}`).join('\n');
605
725
  }
726
+ function parseCompletionShell(value) {
727
+ if (value === 'bash' || value === 'zsh' || value === 'fish')
728
+ return value;
729
+ throw new ConfigurationError('Completion shell must be "bash", "zsh", or "fish".');
730
+ }
606
731
  function parseLimit(value) {
607
732
  const limit = /^\d+$/.test(value) ? Number(value) : Number.NaN;
608
733
  if (!Number.isInteger(limit) || limit < 1 || limit > 10) {