@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.
- package/README.md +68 -6
- package/dist/app.d.ts +10 -2
- package/dist/app.js +207 -82
- package/dist/app.js.map +1 -1
- package/dist/cli.js +4 -2
- 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/storage/config.d.ts +3 -0
- package/dist/storage/config.js +3 -0
- package/dist/storage/config.js.map +1 -1
- package/dist/ui/completions.d.ts +3 -0
- package/dist/ui/completions.js +237 -0
- package/dist/ui/completions.js.map +1 -0
- package/dist/ui/interactive-search.d.ts +36 -0
- package/dist/ui/interactive-search.js +337 -0
- package/dist/ui/interactive-search.js.map +1 -0
- 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/progress.d.ts +6 -0
- package/dist/ui/progress.js +25 -0
- package/dist/ui/progress.js.map +1 -0
- 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/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
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
316
|
+
dependencies.output.log(`✓ Queued ${safe(track.name)} — ${safeArtists(track.artists)}`);
|
|
253
317
|
});
|
|
254
318
|
program
|
|
255
319
|
.command('shuffle')
|
|
256
|
-
.description('
|
|
257
|
-
.argument('
|
|
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
|
-
.
|
|
268
|
-
.
|
|
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
|
-
.
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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 {
|
|
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
|
-
|
|
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) {
|