@crafter/spoti-cli 0.1.1 → 0.2.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/dist/index.js +433 -10
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -74,6 +74,22 @@ import { homedir } from "os";
|
|
|
74
74
|
import { join } from "path";
|
|
75
75
|
var CONFIG_DIR = join(homedir(), ".spoti-cli");
|
|
76
76
|
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
77
|
+
var LEGACY_SCOPES = [
|
|
78
|
+
"playlist-modify-private",
|
|
79
|
+
"playlist-modify-public",
|
|
80
|
+
"user-read-private",
|
|
81
|
+
"user-read-email"
|
|
82
|
+
];
|
|
83
|
+
function requireScopes(needed) {
|
|
84
|
+
const config = readConfig();
|
|
85
|
+
const granted = config.scopes ?? LEGACY_SCOPES;
|
|
86
|
+
const missing = needed.filter((s) => !granted.includes(s));
|
|
87
|
+
if (missing.length > 0) {
|
|
88
|
+
console.error(`This command requires additional permissions: ${missing.join(", ")}`);
|
|
89
|
+
console.error("Run: spoti-cli auth --upgrade");
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
77
93
|
function ensureConfigDir() {
|
|
78
94
|
if (!existsSync(CONFIG_DIR)) {
|
|
79
95
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -94,12 +110,22 @@ function updateConfig(partial) {
|
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
// src/commands/auth.ts
|
|
97
|
-
var
|
|
113
|
+
var BASE_SCOPES = [
|
|
98
114
|
"playlist-modify-private",
|
|
99
115
|
"playlist-modify-public",
|
|
100
116
|
"user-read-private",
|
|
101
117
|
"user-read-email"
|
|
102
118
|
];
|
|
119
|
+
var FULL_SCOPES = [
|
|
120
|
+
...BASE_SCOPES,
|
|
121
|
+
"user-read-playback-state",
|
|
122
|
+
"user-modify-playback-state",
|
|
123
|
+
"user-read-currently-playing",
|
|
124
|
+
"user-top-read",
|
|
125
|
+
"user-library-read",
|
|
126
|
+
"user-library-modify",
|
|
127
|
+
"user-read-recently-played"
|
|
128
|
+
];
|
|
103
129
|
function generateCodeVerifier() {
|
|
104
130
|
const array = new Uint8Array(64);
|
|
105
131
|
crypto.getRandomValues(array);
|
|
@@ -111,7 +137,7 @@ async function generateCodeChallenge(verifier) {
|
|
|
111
137
|
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
112
138
|
return btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
113
139
|
}
|
|
114
|
-
async function authCommand(clientId) {
|
|
140
|
+
async function authCommand(clientId, upgrade = false) {
|
|
115
141
|
const config = readConfig();
|
|
116
142
|
const id = clientId ?? config.client_id;
|
|
117
143
|
if (!id) {
|
|
@@ -125,7 +151,8 @@ async function authCommand(clientId) {
|
|
|
125
151
|
const authUrl = new URL("https://accounts.spotify.com/authorize");
|
|
126
152
|
authUrl.searchParams.set("response_type", "code");
|
|
127
153
|
authUrl.searchParams.set("client_id", id);
|
|
128
|
-
|
|
154
|
+
const scopes = upgrade ? [.../* @__PURE__ */ new Set([...config.scopes ?? BASE_SCOPES, ...FULL_SCOPES])] : FULL_SCOPES;
|
|
155
|
+
authUrl.searchParams.set("scope", scopes.join(" "));
|
|
129
156
|
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
130
157
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
131
158
|
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
@@ -155,7 +182,8 @@ async function authCommand(clientId) {
|
|
|
155
182
|
updateConfig({
|
|
156
183
|
access_token: data.access_token,
|
|
157
184
|
refresh_token: data.refresh_token,
|
|
158
|
-
expires_at: Date.now() + data.expires_in * 1e3
|
|
185
|
+
expires_at: Date.now() + data.expires_in * 1e3,
|
|
186
|
+
scopes
|
|
159
187
|
});
|
|
160
188
|
console.log("Authenticated successfully!");
|
|
161
189
|
console.log("");
|
|
@@ -252,6 +280,74 @@ async function spotify(path, options = {}) {
|
|
|
252
280
|
return res.json();
|
|
253
281
|
}
|
|
254
282
|
|
|
283
|
+
// src/commands/artist.ts
|
|
284
|
+
async function artistGetCommand(id, opts) {
|
|
285
|
+
const data = await spotify(`/artists/${id}`);
|
|
286
|
+
const result = {
|
|
287
|
+
id: data.id,
|
|
288
|
+
name: data.name,
|
|
289
|
+
genres: data.genres.join(", "),
|
|
290
|
+
popularity: data.popularity,
|
|
291
|
+
followers: data.followers.total,
|
|
292
|
+
uri: data.uri,
|
|
293
|
+
url: data.external_urls.spotify
|
|
294
|
+
};
|
|
295
|
+
output(result, opts.json);
|
|
296
|
+
}
|
|
297
|
+
async function artistTopTracksCommand(id, opts) {
|
|
298
|
+
const market = opts.market || "US";
|
|
299
|
+
const data = await spotify(
|
|
300
|
+
`/artists/${id}/top-tracks?market=${market}`
|
|
301
|
+
);
|
|
302
|
+
const items = data.tracks.map((t) => ({
|
|
303
|
+
id: t.id,
|
|
304
|
+
name: t.name,
|
|
305
|
+
artist: t.artists.map((a) => a.name).join(", "),
|
|
306
|
+
album: t.album.name,
|
|
307
|
+
popularity: t.popularity,
|
|
308
|
+
uri: t.uri
|
|
309
|
+
}));
|
|
310
|
+
output(items, opts.json);
|
|
311
|
+
}
|
|
312
|
+
async function artistAlbumsCommand(id, opts) {
|
|
313
|
+
const limit = opts.limit || "20";
|
|
314
|
+
const data = await spotify(
|
|
315
|
+
`/artists/${id}/albums?limit=${limit}&include_groups=album,single`
|
|
316
|
+
);
|
|
317
|
+
const items = data.items.map((a) => ({
|
|
318
|
+
id: a.id,
|
|
319
|
+
name: a.name,
|
|
320
|
+
type: a.album_type,
|
|
321
|
+
released: a.release_date,
|
|
322
|
+
tracks: a.total_tracks,
|
|
323
|
+
uri: a.uri
|
|
324
|
+
}));
|
|
325
|
+
output(items, opts.json);
|
|
326
|
+
}
|
|
327
|
+
async function artistRelatedCommand(id, opts) {
|
|
328
|
+
let data;
|
|
329
|
+
try {
|
|
330
|
+
data = await spotify(
|
|
331
|
+
`/artists/${id}/related-artists`
|
|
332
|
+
);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (err instanceof Error && (err.message.includes("403") || err.message.includes("404"))) {
|
|
335
|
+
console.error("Related artists endpoint is restricted by Spotify. This may require an approved app.");
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
const items = data.artists.map((a) => ({
|
|
341
|
+
id: a.id,
|
|
342
|
+
name: a.name,
|
|
343
|
+
genres: a.genres.join(", "),
|
|
344
|
+
popularity: a.popularity,
|
|
345
|
+
followers: a.followers.total,
|
|
346
|
+
uri: a.uri
|
|
347
|
+
}));
|
|
348
|
+
output(items, opts.json);
|
|
349
|
+
}
|
|
350
|
+
|
|
255
351
|
// src/commands/create.ts
|
|
256
352
|
async function createCommand(name, opts) {
|
|
257
353
|
const user = await spotify("/me");
|
|
@@ -291,6 +387,74 @@ async function createCommand(name, opts) {
|
|
|
291
387
|
}
|
|
292
388
|
}
|
|
293
389
|
|
|
390
|
+
// src/commands/history.ts
|
|
391
|
+
var HISTORY_SCOPES = ["user-read-recently-played"];
|
|
392
|
+
async function historyCommand(opts) {
|
|
393
|
+
requireScopes(HISTORY_SCOPES);
|
|
394
|
+
const limit = opts.limit || "20";
|
|
395
|
+
const data = await spotify(
|
|
396
|
+
`/me/player/recently-played?limit=${limit}`
|
|
397
|
+
);
|
|
398
|
+
const items = data.items.map((i) => ({
|
|
399
|
+
id: i.track.id,
|
|
400
|
+
name: i.track.name,
|
|
401
|
+
artist: i.track.artists.map((a) => a.name).join(", "),
|
|
402
|
+
album: i.track.album.name,
|
|
403
|
+
played_at: i.played_at,
|
|
404
|
+
uri: i.track.uri
|
|
405
|
+
}));
|
|
406
|
+
output(items, opts.json);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/commands/library.ts
|
|
410
|
+
var LIBRARY_READ_SCOPES = ["user-library-read"];
|
|
411
|
+
var LIBRARY_WRITE_SCOPES = ["user-library-read", "user-library-modify"];
|
|
412
|
+
async function libraryListCommand(opts) {
|
|
413
|
+
requireScopes(LIBRARY_READ_SCOPES);
|
|
414
|
+
const limit = opts.limit || "20";
|
|
415
|
+
const offset = opts.offset || "0";
|
|
416
|
+
const data = await spotify(
|
|
417
|
+
`/me/tracks?limit=${limit}&offset=${offset}`
|
|
418
|
+
);
|
|
419
|
+
const items = data.items.map((i) => ({
|
|
420
|
+
id: i.track.id,
|
|
421
|
+
name: i.track.name,
|
|
422
|
+
artist: i.track.artists.map((a) => a.name).join(", "),
|
|
423
|
+
album: i.track.album.name,
|
|
424
|
+
added_at: i.added_at,
|
|
425
|
+
uri: i.track.uri
|
|
426
|
+
}));
|
|
427
|
+
output(items, opts.json);
|
|
428
|
+
}
|
|
429
|
+
async function librarySaveCommand(opts) {
|
|
430
|
+
requireScopes(LIBRARY_WRITE_SCOPES);
|
|
431
|
+
const ids = opts.tracks.split(",").map((t) => t.trim().replace("spotify:track:", ""));
|
|
432
|
+
await spotify("/me/tracks", {
|
|
433
|
+
method: "PUT",
|
|
434
|
+
body: JSON.stringify({ ids })
|
|
435
|
+
});
|
|
436
|
+
console.log(`Saved ${ids.length} track(s) to library`);
|
|
437
|
+
}
|
|
438
|
+
async function libraryRemoveCommand(opts) {
|
|
439
|
+
requireScopes(LIBRARY_WRITE_SCOPES);
|
|
440
|
+
const ids = opts.tracks.split(",").map((t) => t.trim().replace("spotify:track:", ""));
|
|
441
|
+
await spotify("/me/tracks", {
|
|
442
|
+
method: "DELETE",
|
|
443
|
+
body: JSON.stringify({ ids })
|
|
444
|
+
});
|
|
445
|
+
console.log(`Removed ${ids.length} track(s) from library`);
|
|
446
|
+
}
|
|
447
|
+
async function libraryCheckCommand(opts) {
|
|
448
|
+
requireScopes(LIBRARY_READ_SCOPES);
|
|
449
|
+
const ids = opts.tracks.split(",").map((t) => t.trim().replace("spotify:track:", ""));
|
|
450
|
+
const data = await spotify(`/me/tracks/contains?ids=${ids.join(",")}`);
|
|
451
|
+
const items = ids.map((id, i) => ({
|
|
452
|
+
id,
|
|
453
|
+
saved: data[i]
|
|
454
|
+
}));
|
|
455
|
+
output(items, opts.json);
|
|
456
|
+
}
|
|
457
|
+
|
|
294
458
|
// src/commands/me.ts
|
|
295
459
|
async function meCommand(opts) {
|
|
296
460
|
const user = await spotify("/me");
|
|
@@ -305,6 +469,152 @@ async function meCommand(opts) {
|
|
|
305
469
|
output(result, opts.json);
|
|
306
470
|
}
|
|
307
471
|
|
|
472
|
+
// src/commands/player.ts
|
|
473
|
+
var PLAYER_SCOPES = [
|
|
474
|
+
"user-read-playback-state",
|
|
475
|
+
"user-modify-playback-state",
|
|
476
|
+
"user-read-currently-playing"
|
|
477
|
+
];
|
|
478
|
+
function handlePremiumError(err) {
|
|
479
|
+
if (err instanceof Error && err.message.includes("403")) {
|
|
480
|
+
console.error("Spotify Premium required for playback control.");
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
async function playerNowCommand(opts) {
|
|
486
|
+
requireScopes(PLAYER_SCOPES);
|
|
487
|
+
const data = await spotify(
|
|
488
|
+
"/me/player/currently-playing"
|
|
489
|
+
);
|
|
490
|
+
if (!data || !("item" in data)) {
|
|
491
|
+
output({ playing: false }, opts.json);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const result = {
|
|
495
|
+
playing: data.is_playing,
|
|
496
|
+
track: data.item.name,
|
|
497
|
+
artist: data.item.artists.map((a) => a.name).join(", "),
|
|
498
|
+
album: data.item.album.name,
|
|
499
|
+
progress_ms: data.progress_ms,
|
|
500
|
+
duration_ms: data.item.duration_ms,
|
|
501
|
+
device: data.device?.name,
|
|
502
|
+
shuffle: data.shuffle_state,
|
|
503
|
+
repeat: data.repeat_state,
|
|
504
|
+
uri: data.item.uri
|
|
505
|
+
};
|
|
506
|
+
output(result, opts.json);
|
|
507
|
+
}
|
|
508
|
+
async function playerPlayCommand(opts) {
|
|
509
|
+
requireScopes(PLAYER_SCOPES);
|
|
510
|
+
try {
|
|
511
|
+
const params = opts.device ? `?device_id=${opts.device}` : "";
|
|
512
|
+
const body = {};
|
|
513
|
+
if (opts.uri) {
|
|
514
|
+
if (opts.uri.includes(":track:")) {
|
|
515
|
+
body.uris = [opts.uri];
|
|
516
|
+
} else {
|
|
517
|
+
body.context_uri = opts.uri;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
await spotify(`/me/player/play${params}`, {
|
|
521
|
+
method: "PUT",
|
|
522
|
+
body: Object.keys(body).length > 0 ? JSON.stringify(body) : void 0
|
|
523
|
+
});
|
|
524
|
+
console.log("Playing");
|
|
525
|
+
} catch (err) {
|
|
526
|
+
handlePremiumError(err);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function playerPauseCommand() {
|
|
530
|
+
requireScopes(PLAYER_SCOPES);
|
|
531
|
+
try {
|
|
532
|
+
await spotify("/me/player/pause", { method: "PUT" });
|
|
533
|
+
console.log("Paused");
|
|
534
|
+
} catch (err) {
|
|
535
|
+
handlePremiumError(err);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function playerNextCommand() {
|
|
539
|
+
requireScopes(PLAYER_SCOPES);
|
|
540
|
+
try {
|
|
541
|
+
await spotify("/me/player/next", { method: "POST" });
|
|
542
|
+
console.log("Skipped to next");
|
|
543
|
+
} catch (err) {
|
|
544
|
+
handlePremiumError(err);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function playerPrevCommand() {
|
|
548
|
+
requireScopes(PLAYER_SCOPES);
|
|
549
|
+
try {
|
|
550
|
+
await spotify("/me/player/previous", { method: "POST" });
|
|
551
|
+
console.log("Skipped to previous");
|
|
552
|
+
} catch (err) {
|
|
553
|
+
handlePremiumError(err);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async function playerQueueCommand(uri) {
|
|
557
|
+
requireScopes(PLAYER_SCOPES);
|
|
558
|
+
const trackUri = uri.startsWith("spotify:") ? uri : `spotify:track:${uri}`;
|
|
559
|
+
try {
|
|
560
|
+
await spotify(`/me/player/queue?uri=${encodeURIComponent(trackUri)}`, {
|
|
561
|
+
method: "POST"
|
|
562
|
+
});
|
|
563
|
+
console.log(`Added to queue: ${trackUri}`);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
handlePremiumError(err);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async function playerDevicesCommand(opts) {
|
|
569
|
+
requireScopes(PLAYER_SCOPES);
|
|
570
|
+
const data = await spotify("/me/player/devices");
|
|
571
|
+
const items = data.devices.map((d) => ({
|
|
572
|
+
id: d.id,
|
|
573
|
+
name: d.name,
|
|
574
|
+
type: d.type,
|
|
575
|
+
active: d.is_active,
|
|
576
|
+
volume: d.volume_percent
|
|
577
|
+
}));
|
|
578
|
+
output(items, opts.json);
|
|
579
|
+
}
|
|
580
|
+
async function playerVolumeCommand(percent) {
|
|
581
|
+
requireScopes(PLAYER_SCOPES);
|
|
582
|
+
const vol = Number.parseInt(percent);
|
|
583
|
+
if (Number.isNaN(vol) || vol < 0 || vol > 100) {
|
|
584
|
+
console.error("Volume must be 0-100");
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
await spotify(`/me/player/volume?volume_percent=${vol}`, { method: "PUT" });
|
|
589
|
+
console.log(`Volume: ${vol}%`);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
handlePremiumError(err);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async function playerShuffleCommand(state) {
|
|
595
|
+
requireScopes(PLAYER_SCOPES);
|
|
596
|
+
const enabled = state === "on" || state === "true";
|
|
597
|
+
try {
|
|
598
|
+
await spotify(`/me/player/shuffle?state=${enabled}`, { method: "PUT" });
|
|
599
|
+
console.log(`Shuffle: ${enabled ? "on" : "off"}`);
|
|
600
|
+
} catch (err) {
|
|
601
|
+
handlePremiumError(err);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function playerRepeatCommand(state) {
|
|
605
|
+
requireScopes(PLAYER_SCOPES);
|
|
606
|
+
if (!["off", "track", "context"].includes(state)) {
|
|
607
|
+
console.error("Repeat state must be: off, track, or context");
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
await spotify(`/me/player/repeat?state=${state}`, { method: "PUT" });
|
|
612
|
+
console.log(`Repeat: ${state}`);
|
|
613
|
+
} catch (err) {
|
|
614
|
+
handlePremiumError(err);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
308
618
|
// src/commands/playlist.ts
|
|
309
619
|
async function playlistListCommand(opts) {
|
|
310
620
|
const data = await spotify(
|
|
@@ -355,18 +665,18 @@ async function recommendCommand(opts) {
|
|
|
355
665
|
}
|
|
356
666
|
if (opts.seedArtists) {
|
|
357
667
|
for (const artistId of opts.seedArtists.split(",")) {
|
|
358
|
-
const
|
|
668
|
+
const artist2 = await spotify(
|
|
359
669
|
`/artists/${artistId.trim()}`
|
|
360
670
|
);
|
|
361
|
-
queries.push(`artist:${
|
|
671
|
+
queries.push(`artist:${artist2.name}`);
|
|
362
672
|
}
|
|
363
673
|
}
|
|
364
674
|
if (opts.seedTracks) {
|
|
365
675
|
for (const trackId of opts.seedTracks.split(",")) {
|
|
366
|
-
const
|
|
676
|
+
const track2 = await spotify(
|
|
367
677
|
`/tracks/${trackId.trim()}`
|
|
368
678
|
);
|
|
369
|
-
queries.push(`artist:${
|
|
679
|
+
queries.push(`artist:${track2.artists[0].name}`);
|
|
370
680
|
}
|
|
371
681
|
}
|
|
372
682
|
if (queries.length === 0) {
|
|
@@ -434,14 +744,127 @@ async function searchCommand(query, opts) {
|
|
|
434
744
|
}
|
|
435
745
|
}
|
|
436
746
|
|
|
747
|
+
// src/commands/top.ts
|
|
748
|
+
var TOP_SCOPES = ["user-top-read"];
|
|
749
|
+
async function topTracksCommand(opts) {
|
|
750
|
+
requireScopes(TOP_SCOPES);
|
|
751
|
+
const range = opts.range || "medium_term";
|
|
752
|
+
const limit = opts.limit || "20";
|
|
753
|
+
const data = await spotify(
|
|
754
|
+
`/me/top/tracks?time_range=${range}&limit=${limit}`
|
|
755
|
+
);
|
|
756
|
+
const items = data.items.map((t, i) => ({
|
|
757
|
+
rank: i + 1,
|
|
758
|
+
id: t.id,
|
|
759
|
+
name: t.name,
|
|
760
|
+
artist: t.artists.map((a) => a.name).join(", "),
|
|
761
|
+
album: t.album.name,
|
|
762
|
+
popularity: t.popularity,
|
|
763
|
+
uri: t.uri
|
|
764
|
+
}));
|
|
765
|
+
output(items, opts.json);
|
|
766
|
+
}
|
|
767
|
+
async function topArtistsCommand(opts) {
|
|
768
|
+
requireScopes(TOP_SCOPES);
|
|
769
|
+
const range = opts.range || "medium_term";
|
|
770
|
+
const limit = opts.limit || "20";
|
|
771
|
+
const data = await spotify(
|
|
772
|
+
`/me/top/artists?time_range=${range}&limit=${limit}`
|
|
773
|
+
);
|
|
774
|
+
const items = data.items.map((a, i) => ({
|
|
775
|
+
rank: i + 1,
|
|
776
|
+
id: a.id,
|
|
777
|
+
name: a.name,
|
|
778
|
+
genres: a.genres.join(", "),
|
|
779
|
+
popularity: a.popularity,
|
|
780
|
+
uri: a.uri
|
|
781
|
+
}));
|
|
782
|
+
output(items, opts.json);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/commands/track.ts
|
|
786
|
+
var KEY_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
787
|
+
async function trackGetCommand(id, opts) {
|
|
788
|
+
const data = await spotify(`/tracks/${id}`);
|
|
789
|
+
const result = {
|
|
790
|
+
id: data.id,
|
|
791
|
+
name: data.name,
|
|
792
|
+
artist: data.artists.map((a) => a.name).join(", "),
|
|
793
|
+
album: data.album.name,
|
|
794
|
+
released: data.album.release_date,
|
|
795
|
+
duration_ms: data.duration_ms,
|
|
796
|
+
popularity: data.popularity,
|
|
797
|
+
explicit: data.explicit,
|
|
798
|
+
uri: data.uri,
|
|
799
|
+
url: data.external_urls.spotify
|
|
800
|
+
};
|
|
801
|
+
output(result, opts.json);
|
|
802
|
+
}
|
|
803
|
+
async function trackFeaturesCommand(id, opts) {
|
|
804
|
+
let data;
|
|
805
|
+
try {
|
|
806
|
+
data = await spotify(`/audio-features/${id}`);
|
|
807
|
+
} catch (err) {
|
|
808
|
+
if (err instanceof Error && err.message.includes("403")) {
|
|
809
|
+
console.error("Audio features endpoint is restricted by Spotify. This may require an approved app.");
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
throw err;
|
|
813
|
+
}
|
|
814
|
+
const result = {
|
|
815
|
+
danceability: data.danceability,
|
|
816
|
+
energy: data.energy,
|
|
817
|
+
key: KEY_NAMES[data.key] ?? data.key,
|
|
818
|
+
mode: data.mode === 1 ? "major" : "minor",
|
|
819
|
+
loudness: data.loudness,
|
|
820
|
+
speechiness: data.speechiness,
|
|
821
|
+
acousticness: data.acousticness,
|
|
822
|
+
instrumentalness: data.instrumentalness,
|
|
823
|
+
liveness: data.liveness,
|
|
824
|
+
valence: data.valence,
|
|
825
|
+
tempo: Math.round(data.tempo),
|
|
826
|
+
time_signature: data.time_signature,
|
|
827
|
+
duration_ms: data.duration_ms
|
|
828
|
+
};
|
|
829
|
+
output(result, opts.json);
|
|
830
|
+
}
|
|
831
|
+
|
|
437
832
|
// src/index.ts
|
|
438
833
|
var program = new Command();
|
|
439
|
-
program.name("spoti-cli").description("Spotify Web API from your terminal").version("0.
|
|
440
|
-
program.command("auth").description("Authenticate with Spotify (OAuth2 PKCE)").option("-c, --client-id <id>", "Spotify app client ID").action((opts) => authCommand(opts.clientId));
|
|
834
|
+
program.name("spoti-cli").description("Spotify Web API from your terminal").version("0.2.0");
|
|
835
|
+
program.command("auth").description("Authenticate with Spotify (OAuth2 PKCE)").option("-c, --client-id <id>", "Spotify app client ID").option("-u, --upgrade", "Re-authenticate with expanded permissions", false).action((opts) => authCommand(opts.clientId, opts.upgrade));
|
|
441
836
|
program.command("search <query>").description("Search for tracks, artists, or albums").option("-t, --type <type>", "Search type: track, artist, album", "track").option("-l, --limit <n>", "Number of results", "10").option("--json", "Output as JSON", false).action((query, opts) => searchCommand(query, opts));
|
|
442
837
|
program.command("recommend").description("Get track recommendations based on seeds").option("--seed-tracks <ids>", "Comma-separated track IDs").option("--seed-artists <ids>", "Comma-separated artist IDs").option("--seed-genres <genres>", "Comma-separated genres").option("-l, --limit <n>", "Number of recommendations", "20").option("--energy <n>", "Target energy (0.0-1.0)").option("--danceability <n>", "Target danceability (0.0-1.0)").option("--valence <n>", "Target valence/positiveness (0.0-1.0)").option("--tempo <n>", "Target tempo in BPM").option("--popularity <n>", "Target popularity (0-100)").option("--acousticness <n>", "Target acousticness (0.0-1.0)").option("--instrumentalness <n>", "Target instrumentalness (0.0-1.0)").option("--json", "Output as JSON", false).action((opts) => recommendCommand(opts));
|
|
443
838
|
program.command("create <name>").description("Create a new playlist").option("-d, --description <text>", "Playlist description").option("--public", "Make playlist public", false).option("--tracks <uris>", "Comma-separated track URIs or IDs to add").option("-o, --open", "Open playlist in Spotify app after creation", false).option("--json", "Output as JSON", false).action((name, opts) => createCommand(name, opts));
|
|
444
839
|
program.command("me").description("Show current user profile").option("--json", "Output as JSON", false).action((opts) => meCommand(opts));
|
|
840
|
+
program.command("history").description("Recently played tracks").option("-l, --limit <n>", "Number of tracks (max 50)", "20").option("--json", "Output as JSON", false).action((opts) => historyCommand(opts));
|
|
841
|
+
var player = program.command("player").description("Control playback");
|
|
842
|
+
player.command("now").description("Show currently playing track").option("--json", "Output as JSON", false).action((opts) => playerNowCommand(opts));
|
|
843
|
+
player.command("play").description("Start or resume playback").option("--uri <uri>", "Track, album, or playlist URI to play").option("--device <id>", "Device ID to play on").option("--json", "Output as JSON", false).action((opts) => playerPlayCommand(opts));
|
|
844
|
+
player.command("pause").description("Pause playback").action(() => playerPauseCommand());
|
|
845
|
+
player.command("next").description("Skip to next track").action(() => playerNextCommand());
|
|
846
|
+
player.command("prev").description("Skip to previous track").action(() => playerPrevCommand());
|
|
847
|
+
player.command("queue <uri>").description("Add a track to the queue").action((uri) => playerQueueCommand(uri));
|
|
848
|
+
player.command("devices").description("List available devices").option("--json", "Output as JSON", false).action((opts) => playerDevicesCommand(opts));
|
|
849
|
+
player.command("volume <percent>").description("Set volume (0-100)").action((percent) => playerVolumeCommand(percent));
|
|
850
|
+
player.command("shuffle <state>").description("Toggle shuffle (on/off)").action((state) => playerShuffleCommand(state));
|
|
851
|
+
player.command("repeat <state>").description("Set repeat mode (off/track/context)").action((state) => playerRepeatCommand(state));
|
|
852
|
+
var top = program.command("top").description("Your top tracks and artists");
|
|
853
|
+
top.command("tracks").description("Your most listened tracks").option("-r, --range <range>", "Time range: short_term, medium_term, long_term", "medium_term").option("-l, --limit <n>", "Number of results", "20").option("--json", "Output as JSON", false).action((opts) => topTracksCommand(opts));
|
|
854
|
+
top.command("artists").description("Your most listened artists").option("-r, --range <range>", "Time range: short_term, medium_term, long_term", "medium_term").option("-l, --limit <n>", "Number of results", "20").option("--json", "Output as JSON", false).action((opts) => topArtistsCommand(opts));
|
|
855
|
+
var library = program.command("library").description("Manage saved tracks");
|
|
856
|
+
library.command("list").description("List saved tracks").option("-l, --limit <n>", "Number of tracks", "20").option("--offset <n>", "Offset for pagination", "0").option("--json", "Output as JSON", false).action((opts) => libraryListCommand(opts));
|
|
857
|
+
library.command("save").description("Save tracks to library").requiredOption("--tracks <ids>", "Comma-separated track IDs or URIs").action((opts) => librarySaveCommand(opts));
|
|
858
|
+
library.command("remove").description("Remove tracks from library").requiredOption("--tracks <ids>", "Comma-separated track IDs or URIs").action((opts) => libraryRemoveCommand(opts));
|
|
859
|
+
library.command("check").description("Check if tracks are in library").requiredOption("--tracks <ids>", "Comma-separated track IDs or URIs").option("--json", "Output as JSON", false).action((opts) => libraryCheckCommand(opts));
|
|
860
|
+
var track = program.command("track").description("Track details and audio features");
|
|
861
|
+
track.command("get <id>").description("Get track details").option("--json", "Output as JSON", false).action((id, opts) => trackGetCommand(id, opts));
|
|
862
|
+
track.command("features <id>").description("Get audio features (energy, danceability, tempo, key, etc.)").option("--json", "Output as JSON", false).action((id, opts) => trackFeaturesCommand(id, opts));
|
|
863
|
+
var artist = program.command("artist").description("Artist details and discography");
|
|
864
|
+
artist.command("get <id>").description("Get artist details").option("--json", "Output as JSON", false).action((id, opts) => artistGetCommand(id, opts));
|
|
865
|
+
artist.command("top-tracks <id>").description("Get artist's top tracks").option("-m, --market <code>", "Market/country code", "US").option("--json", "Output as JSON", false).action((id, opts) => artistTopTracksCommand(id, opts));
|
|
866
|
+
artist.command("albums <id>").description("Get artist's albums").option("-l, --limit <n>", "Number of albums", "20").option("--json", "Output as JSON", false).action((id, opts) => artistAlbumsCommand(id, opts));
|
|
867
|
+
artist.command("related <id>").description("Get related artists").option("--json", "Output as JSON", false).action((id, opts) => artistRelatedCommand(id, opts));
|
|
445
868
|
var playlist = program.command("playlist").description("Manage playlists");
|
|
446
869
|
playlist.command("list").description("List your playlists").option("-l, --limit <n>", "Number of playlists", "20").option("--json", "Output as JSON", false).action((opts) => playlistListCommand(opts));
|
|
447
870
|
playlist.command("get <id>").description("Get playlist details and tracks").option("--json", "Output as JSON", false).action((id, opts) => playlistGetCommand(id, opts));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crafter/spoti-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Spotify Web API from your terminal. Search,
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Spotify Web API from your terminal. Search, playback, library, audio features, and playlists.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -30,7 +30,11 @@
|
|
|
30
30
|
"cli",
|
|
31
31
|
"playlist",
|
|
32
32
|
"music",
|
|
33
|
-
"terminal"
|
|
33
|
+
"terminal",
|
|
34
|
+
"playback",
|
|
35
|
+
"player",
|
|
36
|
+
"library",
|
|
37
|
+
"audio-features"
|
|
34
38
|
],
|
|
35
39
|
"license": "MIT",
|
|
36
40
|
"repository": {
|