@bzenky/spoti 0.4.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 (39) hide show
  1. package/README.md +19 -7
  2. package/dist/app.d.ts +6 -2
  3. package/dist/app.js +109 -51
  4. package/dist/app.js.map +1 -1
  5. package/dist/cli.js +2 -1
  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/ui/completions.js +10 -10
  23. package/dist/ui/interactive-search.d.ts +4 -1
  24. package/dist/ui/interactive-search.js +218 -55
  25. package/dist/ui/interactive-search.js.map +1 -1
  26. package/dist/ui/output.d.ts +27 -11
  27. package/dist/ui/output.js +87 -45
  28. package/dist/ui/output.js.map +1 -1
  29. package/dist/ui/prompts.d.ts +11 -1
  30. package/dist/ui/prompts.js +65 -13
  31. package/dist/ui/prompts.js.map +1 -1
  32. package/dist/ui/watch.js +3 -2
  33. package/dist/ui/watch.js.map +1 -1
  34. package/dist/utils/errors.js +2 -1
  35. package/dist/utils/errors.js.map +1 -1
  36. package/dist/utils/text.d.ts +2 -0
  37. package/dist/utils/text.js +10 -0
  38. package/dist/utils/text.js.map +1 -0
  39. package/package.json +2 -1
package/README.md CHANGED
@@ -94,7 +94,7 @@ spoti interactive
94
94
  # alias: spoti i
95
95
  ```
96
96
 
97
- Type a query and press Enter. Use `Tab` or left/right to switch between tracks, albums, artists, and playlists; use up/down to select; press Enter to play; press Esc to exit. The terminal is restored even when a request fails.
97
+ Type a query and press Enter. Use `Tab` or left/right to switch between tracks, albums, artists, and playlists; use up/down to select; press Enter to play; press Esc to exit. Type or backspace after a search to edit the query. Category results are cached while the query remains unchanged, and temporary search or playback failures can be retried without reopening the command. The terminal is always restored when the interaction ends.
98
98
 
99
99
  Authenticate once through Spotify's browser authorization page:
100
100
 
@@ -119,8 +119,10 @@ spoti previous
119
119
  spoti volume 50
120
120
  spoti volume +10
121
121
  spoti volume -10
122
+ spoti shuffle
122
123
  spoti shuffle on
123
124
  spoti shuffle off
125
+ spoti repeat
124
126
  spoti repeat off
125
127
  spoti repeat track
126
128
  spoti repeat context
@@ -136,7 +138,7 @@ spoti device "My Computer"
136
138
 
137
139
  Use the displayed one-based number, exact device name, or Spotify device ID.
138
140
 
139
- When playback has no active device, `spoti play` and `spoti resume` automatically target it if exactly one controllable device is available.
141
+ When playback reports no active device, `spoti play` and `spoti resume` retry the one active controllable device if Spotify reports one, or the only controllable device when exactly one is available. If several inactive devices are available, `spoti` asks you to select one explicitly.
140
142
 
141
143
  Seek within the current track:
142
144
 
@@ -146,7 +148,7 @@ spoti seek +30
146
148
  spoti seek -10
147
149
  ```
148
150
 
149
- View the queue or search for a track to add:
151
+ View the queue or search for a track to add. When Spotify represents an otherwise empty queue by repeating only the current track, `spoti` reports the queue as empty instead of printing duplicate entries:
150
152
 
151
153
  ```bash
152
154
  spoti queue
@@ -176,7 +178,7 @@ spoti play playlist "Workout"
176
178
  spoti play playlist 1
177
179
  ```
178
180
 
179
- User playlists are sorted consistently by name, so the displayed number can be reused with `spoti playlist <number>` or `spoti play playlist <number>`.
181
+ User playlists are sorted consistently by name, so the displayed number can be reused with `spoti playlist <number>` or `spoti play playlist <number>`. In an interactive terminal, `spoti playlists` also lets you select a displayed playlist to play immediately; press Enter to leave playback unchanged.
180
182
 
181
183
  In an interactive terminal, `spoti album` can play the entire album or a selected track after showing its details. `spoti artist` can play the artist context or let you select one of the artist’s albums, and `spoti playlist` can start the selected playlist. Non-interactive runs remain display-only and never start playback implicitly.
182
184
 
@@ -193,7 +195,7 @@ spoti recent
193
195
  spoti recent --limit 10
194
196
  ```
195
197
 
196
- `spoti like` and `spoti unlike` operate on the currently playing track. Episodes, advertisements, local files, and unavailable items are ignored safely.
198
+ `spoti like` and `spoti unlike` operate on the currently playing track. Episodes, advertisements, local files, and unavailable items are ignored safely. In an interactive terminal, both `spoti liked` and `spoti recent` show a numbered list and then let you select a track to play. Press Enter without a number to leave playback unchanged; non-interactive runs remain display-only.
197
199
 
198
200
  Check for updates or install the latest npm release:
199
201
 
@@ -212,7 +214,7 @@ spoti logout
212
214
 
213
215
  When attached to an interactive terminal, `spoti play <query>` asks you to select a result. In non-interactive usage it chooses the first result automatically; `--first` makes that behavior explicit.
214
216
 
215
- Watch mode continuously refreshes the current track and progress until `Ctrl+C` is pressed. `--watch` enables it for one command, while `--no-watch` overrides a saved preference.
217
+ Watch mode continuously refreshes the current track and progress until `Ctrl+C` is pressed. `--watch` enables it for one command, while `--no-watch` overrides a saved preference. Run `spoti shuffle` or `spoti repeat` without a value to inspect the current state. Use `spoti shuffle on|off` or `spoti repeat off|track|context` to change it.
216
218
 
217
219
 
218
220
  ### Command aliases
@@ -247,6 +249,16 @@ spoti completion fish | source # Fish
247
249
 
248
250
  Interactive network operations display a spinner on stderr. Indicators remain disabled outside a TTY.
249
251
 
252
+ ### Terminal formatting
253
+
254
+ Interactive terminals use restrained styling for names, metadata, headings, and playback progress. Redirected output remains plain text. Set the standard `NO_COLOR` environment variable to disable decorative styling:
255
+
256
+ ```bash
257
+ NO_COLOR=1 spoti now
258
+ ```
259
+
260
+ Spotify-provided names and descriptions are normalized to safe single-line terminal text before display.
261
+
250
262
  ## Configuration
251
263
 
252
264
  View all settings:
@@ -324,4 +336,4 @@ The release workflow attaches the npm package tarball and a `SHA256SUMS` file, a
324
336
 
325
337
  ## Current scope
326
338
 
327
- Version `0.3.0` supports authentication and persistent client-ID setup; track, album, artist, and playlist discovery and playback; guided playback actions; current playback and watch mode; player, queue, library, device, and update controls. The current development branch adds explicit interactive search, aliases, loading indicators, shell completions, configuration polish, and Node 24-based GitHub Actions. A full Ink-based TUI remains planned for `v1.0.0`.
339
+ Version `0.5.0` improves TTY-aware terminal formatting, interactive-search editing, retries and cancellation, terminal-safe Spotify metadata, collection playback selection, player-state visibility, device fallback, and API error handling. A full Ink-based TUI remains planned for `v1.0.0`.
package/dist/app.d.ts CHANGED
@@ -10,8 +10,8 @@ import type { RecentService } from './services/recent.service.js';
10
10
  import type { SearchService } from './services/search.service.js';
11
11
  import { type UpdateService } from './services/update.service.js';
12
12
  import { type ConfigStore } from './storage/config.js';
13
- import { type Output } from './ui/output.js';
14
- import { confirmUpdate, promptSpotifyClientId, selectAlbum, selectAlbumAction, selectArtist, selectArtistAction, selectPlaylist, selectPlaylistAction, selectTrack } from './ui/prompts.js';
13
+ import { type Output, type OutputStyles } from './ui/output.js';
14
+ import { confirmUpdate, promptSpotifyClientId, selectAlbum, selectAlbumAction, selectArtist, selectArtistAction, selectLikedTrack, selectListedPlaylist, selectPlaylist, selectPlaylistAction, selectRecentTrack, selectTrack } from './ui/prompts.js';
15
15
  import { type ProgressRunner } from './ui/progress.js';
16
16
  import { type InteractiveSearchResult } from './ui/interactive-search.js';
17
17
  import { type PlaybackWatcher } from './ui/watch.js';
@@ -32,13 +32,17 @@ export interface AppDependencies {
32
32
  chooseAlbum?: typeof selectAlbum;
33
33
  chooseArtist?: typeof selectArtist;
34
34
  choosePlaylist?: typeof selectPlaylist;
35
+ chooseListedPlaylist?: typeof selectListedPlaylist;
35
36
  chooseAlbumAction?: typeof selectAlbumAction;
36
37
  chooseArtistAction?: typeof selectArtistAction;
37
38
  choosePlaylistAction?: typeof selectPlaylistAction;
39
+ chooseLikedTrack?: typeof selectLikedTrack;
40
+ chooseRecentTrack?: typeof selectRecentTrack;
38
41
  requestSpotifyClientId?: typeof promptSpotifyClientId;
39
42
  confirmUpdate?: typeof confirmUpdate;
40
43
  interactiveSearch?: () => Promise<InteractiveSearchResult>;
41
44
  progress?: ProgressRunner;
45
+ styles?: OutputStyles;
42
46
  watchPlayback?: PlaybackWatcher;
43
47
  }
44
48
  export declare function createProgram(dependencies: AppDependencies): Command;
package/dist/app.js CHANGED
@@ -1,8 +1,8 @@
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
6
  import { generateCompletionScript } from './ui/completions.js';
7
7
  import { withProgress } from './ui/progress.js';
8
8
  import { runInteractiveSearch, } from './ui/interactive-search.js';
@@ -16,26 +16,33 @@ export function createProgram(dependencies) {
16
16
  const chooseAlbum = dependencies.chooseAlbum ?? selectAlbum;
17
17
  const chooseArtist = dependencies.chooseArtist ?? selectArtist;
18
18
  const choosePlaylist = dependencies.choosePlaylist ?? selectPlaylist;
19
+ const chooseListedPlaylist = dependencies.chooseListedPlaylist ?? selectListedPlaylist;
19
20
  const chooseAlbumAction = dependencies.chooseAlbumAction ?? selectAlbumAction;
20
21
  const chooseArtistAction = dependencies.chooseArtistAction ?? selectArtistAction;
21
22
  const choosePlaylistAction = dependencies.choosePlaylistAction ?? selectPlaylistAction;
23
+ const chooseLikedTrack = dependencies.chooseLikedTrack ?? selectLikedTrack;
24
+ const chooseRecentTrack = dependencies.chooseRecentTrack ?? selectRecentTrack;
22
25
  const requestClientId = dependencies.requestSpotifyClientId ?? promptSpotifyClientId;
23
26
  const requestUpdateConfirmation = dependencies.confirmUpdate ?? confirmUpdate;
27
+ const styles = dependencies.styles ?? plainOutputStyles;
28
+ const safe = sanitizeOneLineText;
29
+ const safeArtists = (artists) => artists.map(safe).join(', ');
24
30
  const startInteractiveSearch = dependencies.interactiveSearch ??
25
31
  (() => runInteractiveSearch({
26
32
  search: dependencies.search,
27
33
  player: dependencies.player,
34
+ styles,
28
35
  }));
29
36
  const showProgress = dependencies.progress ?? withProgress;
30
37
  const runTask = (label, task) => showProgress(label, task);
31
38
  const startWatching = dependencies.watchPlayback ?? watchPlayback;
32
39
  const runAlbumAction = async (album) => {
33
40
  const detail = await runTask('Loading album…', () => dependencies.catalog.getAlbum(album.id));
34
- dependencies.output.log(formatAlbumDetail(detail));
41
+ dependencies.output.log(formatAlbumDetail(detail, styles));
35
42
  const action = await chooseAlbumAction();
36
43
  if (action === 'play-album') {
37
44
  await dependencies.player.playContext(detail.uri);
38
- dependencies.output.log(`▶ Playing album ${detail.name}`);
45
+ dependencies.output.log(`▶ Playing album ${safe(detail.name)}`);
39
46
  return;
40
47
  }
41
48
  if (action === 'play-track') {
@@ -43,14 +50,21 @@ export function createProgram(dependencies) {
43
50
  if (!track)
44
51
  return;
45
52
  await dependencies.player.playTrack(track.uri);
46
- dependencies.output.log(`▶ Playing ${track.name} — ${track.artists.join(', ')}`);
53
+ dependencies.output.log(`▶ Playing ${safe(track.name)} — ${safeArtists(track.artists)}`);
47
54
  }
48
55
  };
49
56
  program
50
57
  .name('spoti')
51
58
  .description('Control Spotify from your terminal')
52
59
  .version(VERSION)
53
- .action(() => program.outputHelp());
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
+ });
54
68
  const interactiveCommand = program
55
69
  .command('interactive')
56
70
  .alias('i')
@@ -59,6 +73,9 @@ export function createProgram(dependencies) {
59
73
  const result = await startInteractiveSearch();
60
74
  if (result.status === 'not-interactive')
61
75
  interactiveCommand.outputHelp();
76
+ else if (result.status === 'played') {
77
+ dependencies.output.log(`▶ Playing ${styles.name(safe(result.label))}`);
78
+ }
62
79
  });
63
80
  program
64
81
  .command('setup')
@@ -79,7 +96,7 @@ export function createProgram(dependencies) {
79
96
  .action(async () => {
80
97
  dependencies.output.log('Opening Spotify authorization in your browser…');
81
98
  const user = await dependencies.auth.login();
82
- dependencies.output.log(`✓ Logged in as ${user.displayName}`);
99
+ dependencies.output.log(`✓ Logged in as ${safe(user.displayName)}`);
83
100
  });
84
101
  program
85
102
  .command('logout')
@@ -97,7 +114,7 @@ export function createProgram(dependencies) {
97
114
  return;
98
115
  }
99
116
  const user = await dependencies.auth.getCurrentUser();
100
- dependencies.output.log(`✓ Logged in as ${user.displayName}`);
117
+ dependencies.output.log(`✓ Logged in as ${safe(user.displayName)}`);
101
118
  });
102
119
  const configCommand = program
103
120
  .command('config')
@@ -166,7 +183,7 @@ export function createProgram(dependencies) {
166
183
  return;
167
184
  }
168
185
  const playback = await dependencies.player.getCurrentPlayback();
169
- dependencies.output.log(playback ? formatPlayback(playback) : 'Nothing is currently playing.');
186
+ dependencies.output.log(playback ? formatPlayback(playback, styles) : 'Nothing is currently playing.');
170
187
  });
171
188
  program
172
189
  .command('pause')
@@ -216,7 +233,9 @@ export function createProgram(dependencies) {
216
233
  ? 'restricted'
217
234
  : 'available';
218
235
  const volume = device.volumePercent === null ? '' : ` · ${device.volumePercent}%`;
219
- return `${index + 1}. ${device.name} · ${device.type} · ${state}${volume}`;
236
+ const name = styles.name(sanitizeOneLineText(device.name));
237
+ const metadata = styles.metadata(`${sanitizeOneLineText(device.type)} · ${state}${volume}`);
238
+ return `${index + 1}. ${name} · ${metadata}`;
220
239
  })
221
240
  .join('\n');
222
241
  dependencies.output.log(formatted);
@@ -231,7 +250,7 @@ export function createProgram(dependencies) {
231
250
  if (!device.id)
232
251
  throw new ConfigurationError('The selected device has no usable ID.');
233
252
  await dependencies.device.transferPlayback(device.id);
234
- dependencies.output.log(`✓ Active device: ${device.name}`);
253
+ dependencies.output.log(`✓ Active device: ${safe(device.name)}`);
235
254
  });
236
255
  program
237
256
  .command('seek')
@@ -270,22 +289,22 @@ export function createProgram(dependencies) {
270
289
  const playbackQueue = await runTask('Loading queue…', () => dependencies.queue.getQueue());
271
290
  const lines = playbackQueue.currentlyPlaying
272
291
  ? [
273
- `Now: ${formatQueueItem(playbackQueue.currentlyPlaying)}`,
292
+ `${styles.heading('Now:')} ${formatQueueItem(playbackQueue.currentlyPlaying, styles)}`,
274
293
  '',
275
- 'Up next:',
294
+ styles.heading('Up next:'),
276
295
  ]
277
- : ['Up next:'];
296
+ : [styles.heading('Up next:')];
278
297
  if (playbackQueue.queue.length === 0)
279
298
  lines.push('Queue is empty.');
280
299
  else {
281
- lines.push(...playbackQueue.queue.map((item, index) => `${index + 1}. ${formatQueueItem(item)}`));
300
+ lines.push(...playbackQueue.queue.map((item, index) => `${index + 1}. ${formatQueueItem(item, styles)}`));
282
301
  }
283
302
  dependencies.output.log(lines.join('\n'));
284
303
  return;
285
304
  }
286
305
  const tracks = await dependencies.search.searchTracks(query);
287
306
  if (tracks.length === 0) {
288
- dependencies.output.log(`No tracks found for "${query}".`);
307
+ dependencies.output.log(`No tracks found for "${safe(query)}".`);
289
308
  return;
290
309
  }
291
310
  const track = options.first ? tracks[0] : await chooseTrack(tracks);
@@ -294,13 +313,18 @@ export function createProgram(dependencies) {
294
313
  return;
295
314
  }
296
315
  await dependencies.queue.addItem(track.uri);
297
- dependencies.output.log(`✓ Queued ${track.name} — ${track.artists.join(', ')}`);
316
+ dependencies.output.log(`✓ Queued ${safe(track.name)} — ${safeArtists(track.artists)}`);
298
317
  });
299
318
  program
300
319
  .command('shuffle')
301
- .description('Turn playback shuffle on or off')
302
- .argument('<state>', 'on or off')
320
+ .description('Show or change the playback shuffle state')
321
+ .argument('[state]', 'on or off')
303
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
+ }
304
328
  if (state !== 'on' && state !== 'off') {
305
329
  throw new ConfigurationError('Shuffle state must be either "on" or "off".');
306
330
  }
@@ -310,9 +334,14 @@ export function createProgram(dependencies) {
310
334
  program
311
335
  .command('repeat')
312
336
  .alias('rep')
313
- .description('Set the playback repeat mode')
314
- .argument('<mode>', 'off, track, or context')
337
+ .description('Show or change the playback repeat mode')
338
+ .argument('[mode]', 'off, track, or context')
315
339
  .action(async (mode) => {
340
+ if (mode === undefined) {
341
+ const currentMode = await dependencies.player.getRepeatState();
342
+ dependencies.output.log(`🔁 Repeat: ${currentMode}`);
343
+ return;
344
+ }
316
345
  if (mode !== 'off' && mode !== 'track' && mode !== 'context') {
317
346
  throw new ConfigurationError('Repeat mode must be "off", "track", or "context".');
318
347
  }
@@ -328,7 +357,7 @@ export function createProgram(dependencies) {
328
357
  const query = queryParts.join(' ').trim();
329
358
  const albums = await runTask('Searching albums…', () => dependencies.search.searchAlbums(query));
330
359
  if (albums.length === 0) {
331
- dependencies.output.log(`No albums found for "${query}".`);
360
+ dependencies.output.log(`No albums found for "${safe(query)}".`);
332
361
  return;
333
362
  }
334
363
  const album = options.first ? albums[0] : await chooseAlbum(albums);
@@ -347,7 +376,7 @@ export function createProgram(dependencies) {
347
376
  const query = queryParts.join(' ').trim();
348
377
  const artists = await runTask('Searching artists…', () => dependencies.search.searchArtists(query));
349
378
  if (artists.length === 0) {
350
- dependencies.output.log(`No artists found for "${query}".`);
379
+ dependencies.output.log(`No artists found for "${safe(query)}".`);
351
380
  return;
352
381
  }
353
382
  const artist = options.first ? artists[0] : await chooseArtist(artists);
@@ -356,17 +385,17 @@ export function createProgram(dependencies) {
356
385
  return;
357
386
  }
358
387
  const detail = await runTask('Loading artist…', () => dependencies.catalog.getArtist(artist.id));
359
- dependencies.output.log(formatArtistDetail(detail));
388
+ dependencies.output.log(formatArtistDetail(detail, styles));
360
389
  const action = await chooseArtistAction();
361
390
  if (action === 'play-artist') {
362
391
  await dependencies.player.playContext(detail.uri);
363
- dependencies.output.log(`▶ Playing artist ${detail.name}`);
392
+ dependencies.output.log(`▶ Playing artist ${safe(detail.name)}`);
364
393
  return;
365
394
  }
366
395
  if (action === 'select-album') {
367
396
  const albums = await runTask('Loading artist albums…', () => dependencies.catalog.getArtistAlbums(detail.id));
368
397
  if (albums.length === 0) {
369
- dependencies.output.log(`No albums found for ${detail.name}.`);
398
+ dependencies.output.log(`No albums found for ${safe(detail.name)}.`);
370
399
  return;
371
400
  }
372
401
  const album = await chooseAlbum(albums);
@@ -377,13 +406,22 @@ export function createProgram(dependencies) {
377
406
  program
378
407
  .command('playlists')
379
408
  .alias('pls')
380
- .description('List your Spotify playlists')
409
+ .description('List and optionally play your Spotify playlists')
381
410
  .option('-l, --limit <number>', 'maximum number of playlists', parseCollectionLimit, 50)
382
411
  .action(async (options) => {
383
412
  const playlists = await runTask('Loading playlists…', () => dependencies.playlist.listPlaylists(options.limit));
384
- dependencies.output.log(playlists.length === 0
385
- ? 'No playlists found.'
386
- : playlists.map((playlist, index) => formatPlaylist(playlist, index)).join('\n'));
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)}`);
387
425
  });
388
426
  program
389
427
  .command('playlist')
@@ -401,7 +439,7 @@ export function createProgram(dependencies) {
401
439
  else {
402
440
  const matches = allPlaylists.filter((playlist) => playlist.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
403
441
  if (matches.length === 0) {
404
- dependencies.output.log(`No playlists found for "${query}".`);
442
+ dependencies.output.log(`No playlists found for "${safe(query)}".`);
405
443
  return;
406
444
  }
407
445
  playlist = options.first ? matches[0] : await choosePlaylist(matches);
@@ -410,21 +448,28 @@ export function createProgram(dependencies) {
410
448
  dependencies.output.log('Selection cancelled.');
411
449
  return;
412
450
  }
413
- dependencies.output.log(formatPlaylistOverview(playlist));
451
+ dependencies.output.log(formatPlaylistOverview(playlist, styles));
414
452
  if ((await choosePlaylistAction()) === 'play-playlist') {
415
453
  await dependencies.player.playContext(playlist.uri);
416
- dependencies.output.log(`▶ Playing playlist ${playlist.name}`);
454
+ dependencies.output.log(`▶ Playing playlist ${safe(playlist.name)}`);
417
455
  }
418
456
  });
419
457
  program
420
458
  .command('liked')
421
- .description('List your liked tracks')
459
+ .description('List and optionally play your liked tracks')
422
460
  .option('-l, --limit <number>', 'maximum number of tracks', parseCollectionLimit, 20)
423
461
  .action(async (options) => {
424
462
  const tracks = await runTask('Loading liked tracks…', () => dependencies.library.getLikedTracks(options.limit));
425
- dependencies.output.log(tracks.length === 0
426
- ? 'No liked tracks found.'
427
- : tracks.map((item, index) => formatSavedTrack(item, index)).join('\n'));
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)}`);
428
473
  });
429
474
  program
430
475
  .command('like')
@@ -432,7 +477,7 @@ export function createProgram(dependencies) {
432
477
  .action(async () => {
433
478
  const track = await dependencies.library.likeCurrentTrack();
434
479
  dependencies.output.log(track
435
- ? `♥ Liked ${track.name} — ${track.artists.join(', ')}`
480
+ ? `♥ Liked ${safe(track.name)} — ${safeArtists(track.artists)}`
436
481
  : 'No playable track is currently selected.');
437
482
  });
438
483
  program
@@ -441,19 +486,26 @@ export function createProgram(dependencies) {
441
486
  .action(async () => {
442
487
  const track = await dependencies.library.unlikeCurrentTrack();
443
488
  dependencies.output.log(track
444
- ? `♡ Unliked ${track.name} — ${track.artists.join(', ')}`
489
+ ? `♡ Unliked ${safe(track.name)} — ${safeArtists(track.artists)}`
445
490
  : 'No playable track is currently selected.');
446
491
  });
447
492
  program
448
493
  .command('recent')
449
494
  .alias('rec')
450
- .description('Show recently played tracks')
495
+ .description('Show and optionally play recently played tracks')
451
496
  .option('-l, --limit <number>', 'maximum number of tracks', parseCollectionLimit, 20)
452
497
  .action(async (options) => {
453
498
  const tracks = await runTask('Loading recent tracks…', () => dependencies.recent.getRecentlyPlayed(options.limit));
454
- dependencies.output.log(tracks.length === 0
455
- ? 'No recently played tracks found.'
456
- : tracks.map((item, index) => formatRecentlyPlayed(item, index)).join('\n'));
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)}`);
457
509
  });
458
510
  program
459
511
  .command('update')
@@ -495,10 +547,10 @@ export function createProgram(dependencies) {
495
547
  const query = queryParts.join(' ');
496
548
  const tracks = await runTask('Searching tracks…', () => dependencies.search.searchTracks(query, options.limit));
497
549
  if (tracks.length === 0) {
498
- dependencies.output.log(`No tracks found for "${query}".`);
550
+ dependencies.output.log(`No tracks found for "${safe(query)}".`);
499
551
  return;
500
552
  }
501
- 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'));
502
554
  });
503
555
  program
504
556
  .command('play')
@@ -545,7 +597,7 @@ export function createProgram(dependencies) {
545
597
  await dependencies.player.playTrack(selection.uri);
546
598
  else
547
599
  await dependencies.player.playContext(selection.uri);
548
- dependencies.output.log(`▶ Playing ${selection.label}`);
600
+ dependencies.output.log(`▶ Playing ${safe(selection.label)}`);
549
601
  if (shouldWatch) {
550
602
  await startWatching({
551
603
  player: dependencies.player,
@@ -600,10 +652,14 @@ async function selectPlaybackItem(options) {
600
652
  return {
601
653
  type: 'track',
602
654
  uri: track.uri,
603
- label: `${track.name} — ${track.artists.join(', ')}`,
655
+ label: `${sanitizeOneLineText(track.name)} — ${track.artists.map(sanitizeOneLineText).join(', ')}`,
604
656
  };
605
657
  }
606
- 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
+ };
607
663
  }
608
664
  function findNumberedPlaylist(playlists, number) {
609
665
  const index = Number(number) - 1;
@@ -614,7 +670,7 @@ function findNumberedPlaylist(playlists, number) {
614
670
  return playlist;
615
671
  }
616
672
  function reportNoResults(output, type, query) {
617
- output.log(`No ${type} found for "${query}".`);
673
+ output.log(`No ${type} found for "${sanitizeOneLineText(query)}".`);
618
674
  return null;
619
675
  }
620
676
  function isSpotifyPlayableType(value) {
@@ -638,8 +694,10 @@ function parseSeekInput(value) {
638
694
  milliseconds: sign * (minutes * 60 + seconds) * 1_000,
639
695
  };
640
696
  }
641
- function formatQueueItem(item) {
642
- 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}`;
643
701
  }
644
702
  function parseVolumeInput(value) {
645
703
  if (/^[+-]\d+$/.test(value)) {