@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.
- package/README.md +19 -7
- package/dist/app.d.ts +6 -2
- package/dist/app.js +109 -51
- package/dist/app.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/services/device.service.d.ts +3 -3
- package/dist/services/device.service.js +19 -11
- package/dist/services/device.service.js.map +1 -1
- package/dist/services/player.service.d.ts +4 -2
- package/dist/services/player.service.js +35 -5
- package/dist/services/player.service.js.map +1 -1
- package/dist/services/queue.service.js +11 -6
- package/dist/services/queue.service.js.map +1 -1
- package/dist/services/search.service.d.ts +4 -4
- package/dist/services/search.service.js +10 -9
- package/dist/services/search.service.js.map +1 -1
- package/dist/spotify/client.d.ts +2 -0
- package/dist/spotify/client.js +170 -46
- package/dist/spotify/client.js.map +1 -1
- package/dist/spotify/types.d.ts +2 -0
- package/dist/ui/completions.js +10 -10
- package/dist/ui/interactive-search.d.ts +4 -1
- package/dist/ui/interactive-search.js +218 -55
- package/dist/ui/interactive-search.js.map +1 -1
- package/dist/ui/output.d.ts +27 -11
- package/dist/ui/output.js +87 -45
- package/dist/ui/output.js.map +1 -1
- package/dist/ui/prompts.d.ts +11 -1
- package/dist/ui/prompts.js +65 -13
- package/dist/ui/prompts.js.map +1 -1
- package/dist/ui/watch.js +3 -2
- package/dist/ui/watch.js.map +1 -1
- package/dist/utils/errors.js +2 -1
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/text.d.ts +2 -0
- package/dist/utils/text.js +10 -0
- package/dist/utils/text.js.map +1 -0
- 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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
316
|
+
dependencies.output.log(`✓ Queued ${safe(track.name)} — ${safeArtists(track.artists)}`);
|
|
298
317
|
});
|
|
299
318
|
program
|
|
300
319
|
.command('shuffle')
|
|
301
|
-
.description('
|
|
302
|
-
.argument('
|
|
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('
|
|
314
|
-
.argument('
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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 {
|
|
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
|
-
|
|
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)) {
|