UNPKG

spotify-cli-util

Version:

A simple command line utility for controlling any Spotify client.

720 lines (658 loc) 17.9 kB
import { program } from "commander"; import { randomUUID } from "crypto"; require("dotenv").config(); const openB = require("open"); const chalk = require("chalk"); const Conf = require("conf"); const readline = require("readline").createInterface({ input: process.stdin, output: process.stdout, }); const link = require("terminal-link"); const config = new Conf({ projectName: "spotify-cli" }); const REDIRECT_URI = "https://spotify-cli-web.herokuapp.com/"; export default class SpotifyCLI { constructor() {} scopes = [ "user-read-private", "user-read-email", "user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing", "user-read-recently-played", "user-read-playback-position", "user-top-read", "user-read-playback-state", "playlist-read-collaborative", "playlist-modify-public", "playlist-modify-private", "playlist-read-private", ]; private headers = { Authorization: `Bearer ${config.get("access-token")}`, "Content-Type": "application/json", }; private async requiresLogin() { if (!config.get("access-token")) { console.log(chalk.red("You are not logged in!")); console.log(chalk.blue("Login to continue...")); // return false; process.exit(0); } if (config.get("expires-in") < Date.now() - 2000) { const re = await this.getNewAccessToken().then(() => { return true; }); } } async browse(input: string) { console.log(`Opening ${input} in Spotify Web Browser...`); await openB(`https://open.spotify.com/search/${input}`); console.log( chalk.green( "If your browser doesn't open automatically, please open the following link manually:" ) ); console.log( chalk.underline.blue(`https://open.spotify.com/search/${input}`) ); } async login() { console.log("Logging in..."); // generate a random UUID const uuid = randomUUID(); const url = "https://accounts.spotify.com/authorize?" + new URLSearchParams({ response_type: "code", client_id: process.env.CLIENT_ID as string, redirect_uri: REDIRECT_URI as string, scope: this.scopes.join(" "), state: uuid, }); await openB(url); console.log( `If your browser doesn't open automatically, please open the following link manually:` ); console.log(chalk.underline.blue(url)); // Input from user // await readline.question( // "Enter the code from browser to continue: ", // (code: string) => { // config.set("access-token", code); // readline.close(); // fetch("https://accounts.spotify.com/api/token", { // method: "POST", // // @ts-ignore // body: new URLSearchParams({ // code: code, // redirect_uri: REDIRECT_URI, // grant_type: "authorization_code", // }), // headers: { // Authorization: // "Basic " + // new Buffer( // process.env.CLIENT_ID + // ":" + // process.env.CLIENT_SECRET // ).toString("base64"), // "Content-Type": "application/x-www-form-urlencoded", // }, // }) // .then(async (response) => { // const re = await response.json(); // config.set("access-token", re.access_token); // config.set("refresh-token", re.refresh_token); // config.set("expires-in", re.expires_in + Date.now()); // console.log( // chalk.green( // "Successfully logged in! You can now continue to use Spotify from your terminal." // ) // ); // }) // .catch((err) => { // console.log("Failed to log in! Try again later."); // }); // } // ); // Wait for 5 seconds await new Promise((resolve) => setTimeout(resolve, 5000)); const code = await this.getFromServer(uuid); if (code) { config.set("access-token", code); const res = await fetch("https://accounts.spotify.com/api/token", { method: "POST", body: new URLSearchParams({ code: code, redirect_uri: REDIRECT_URI as string, grant_type: "authorization_code", }), headers: { Authorization: "Basic " + new Buffer( process.env.CLIENT_ID + ":" + process.env.CLIENT_SECRET ).toString("base64"), "Content-Type": "application/x-www-form-urlencoded", }, }); if (res.status !== 200) { console.log("Failed to log in! Try again later."); process.exit(0); } const re = await res.json(); config.set("access-token", re.access_token); config.set("refresh-token", re.refresh_token); config.set("expires-in", re.expires_in + Date.now()); console.log( chalk.green( "Successfully logged in! You can now continue to use Spotify from your terminal." ) ); } else { console.log("Failed to log in! Try again later."); } process.exit(0); } private async getFromServer(state: string): Promise<string> { const parms = new URLSearchParams({ state: state, }); const res = await fetch( `${REDIRECT_URI}get?${parms.toString()}` as string, { headers: { "Content-Type": "application/json", }, method: "GET", credentials: "include", } ); const re = await res.json(); if (re.code) { return re.code; } else { await new Promise((resolve) => setTimeout(resolve, 5000)); return await this.getFromServer(state); } } async getNewAccessToken() { const refresh_token = config.get("refresh-token"); if (refresh_token) { await fetch("https://accounts.spotify.com/api/token", { method: "POST", // @ts-ignore body: new URLSearchParams({ refresh_token: refresh_token, grant_type: "refresh_token", }), headers: { Authorization: "Basic " + new Buffer( process.env.CLIENT_ID + ":" + process.env.CLIENT_SECRET ).toString("base64"), "Content-Type": "application/x-www-form-urlencoded", }, }) .then((r) => r.json()) .then((response) => { config.set("access-token", response.access_token); config.set( "expires-in", response.expires_in * 1000 + Date.now() ); // ! TODO: REMOVE NEXT LINE console.warn("Access token refreshed!"); }) .catch((err) => { console.error("Failed to log in! Try again later."); }); } else { console.log(chalk.red("You are not logged in!")); console.log(chalk.blue("Login to continue...")); } } async currentlyPlaying() { await this.requiresLogin(); fetch("https://api.spotify.com/v1/me/player/currently-playing", { headers: this.headers, }) .then(async (response) => { const re = await response.json(); const tLink = link(re.item.name, re.item.external_urls.spotify); console.log(chalk.magenta.bold(`Currently playing: ${tLink} `)); console.log( chalk.green( `By ` + chalk.blue(re.item.artists[0].name) + ` from album ` ) + chalk.blue(re.item.album.name) ); var currentTime = Math.floor((re.progress_ms / 1000 / 60) % 60) + ":" + Math.floor((re.progress_ms / 1000) % 60); var totalTime = Math.floor((re.item.duration_ms / 1000 / 60) % 60) + ":" + Math.floor((re.item.duration_ms / 1000) % 60); console.log( chalk.green( `\n` + `Progress: ` + chalk.blue(currentTime) + `/` + chalk.blue(totalTime) ) ); process.exit(0); }) .catch((err) => { console.log(chalk.red("Nothing is currently playing!")); console.log( "To play a song, use play <song name> or play <song name> <artist name>" ); process.exit(0); }); } async devices(flag: number = 0) { await this.requiresLogin(); const res = await fetch( "https://api.spotify.com/v1/me/player/devices", { headers: this.headers, } ); const re = await res.json(); console.log(chalk.green("Available devices:")); re.devices.forEach((device: any, index: number) => { if (device.is_active) { console.log( index + 1 + ") " + chalk.blue.bgGreen(`${device.name}`) ); } else { console.log(index + 1 + ") " + chalk.blue(`${device.name}`)); } }); config.set("devices", re.devices); if (flag == 0) { console.log( `\n` + chalk.green( `To play a song, use play <song name> or play <song name> <artist name>` ) ); process.exit(0); } return re.devices; } async switchDevice(device?: number, flag: number = 0) { await this.requiresLogin(); if (!device) { const k = await this.devices(1).then(async () => { await readline.question( "Pick a device to switch to: (press q to quit): ", async (num: string) => { if (num == "q" || num == "Q") { process.exit(0); } if (isNaN(parseInt(num))) { console.log(chalk.red("Invalid device!")); process.exit(0); } await this.switchDevice(parseInt(num), 1); } ); }); } else if (device && flag == 1) { const re = await fetch("https://api.spotify.com/v1/me/player", { method: "PUT", headers: this.headers, body: JSON.stringify({ device_ids: [config.get("devices")[device - 1].id], }), }); if (re.status == 204) { console.log(chalk.green("Switched device!")); process.exit(0); } else { console.log(chalk.red("Failed to switch device!")); process.exit(0); } } else { const re = await fetch("https://api.spotify.com/v1/me/player", { method: "PUT", headers: this.headers, body: JSON.stringify({ device_ids: [device], }), }); if (re.status == 204) { console.log(chalk.green("Switched device!")); process.exit(0); } else { console.log( chalk.red("Failed to switch device! Incorrect device ID!") ); process.exit(0); } } } async recentlyPlayed() { await this.requiresLogin(); const re = await this.getRecentlyPlayed(); console.log(chalk.green("Recently played:")); re.forEach((item: any) => { console.log( chalk.blue(item.track.name) + " by " + chalk.blue(item.track.artists[0].name) ); }); process.exit(0); } private async getRecentlyPlayed() { await this.requiresLogin(); const res = await fetch( "https://api.spotify.com/v1/me/player/recently-played", { headers: this.headers, } ); const re = await res.json(); return re.items; } async play( track?: string, artist?: string, playlist?: string, device?: number, album?: string ) { await this.requiresLogin(); if (!track && !artist && !playlist && !device && !album) { const res = await fetch( "https://api.spotify.com/v1/me/player/play", { method: "PUT", headers: this.headers, } ); if (res.status == 204) { console.log(chalk.green("Resumed playback!")); } else if (res.status == 403) { } else { console.log(chalk.red("Failed to resume playback!")); } } else if (device && !track && !artist && !playlist) { await this.switchDevice(device); } else { let id = await this.search(track ? track : album, artist, playlist); if (id) { if (id.status != 400) { var query = null; if (device) { query = new URLSearchParams({ device_id: device.toString(), }); } var body: any = {}; if (id.trackId) { body = { context_uri: id.albumId ? id.albumId : id.artistId ? id.artistId : id.playlistId, offset: { uri: id.trackId, }, }; } else { body = { context_uri: id.albumId ? id.albumId : id.artistId ? id.artistId : id.playlistId, }; } const res = await fetch( `https://api.spotify.com/v1/me/player/play?${ query ? query.toString() : "" }`, { method: "PUT", headers: this.headers, body: JSON.stringify(body), } ); if (res.status == 204) { var output; if (id.trackName) { output = `Playing ${id.trackName} by ${id.artistName}`; } else if (id.playlistName) { output = `Playing ${id.playlistName}`; } else if (id.albumName) { output = `Playing ${id.albumName}`; } console.log(chalk.green(output)); } else { console.log(chalk.red("Failed to play song!")); } } else { console.log(chalk.red("Invalid search!")); } } else { console.log(chalk.red("Failed to play song!")); } } process.exit(0); } async search( track: string | undefined, artist: string | undefined, playlist: string | undefined ): Promise<{ status: number; trackId?: string; artistId?: string; playlistId?: string; albumId?: string; trackName?: string; artistName?: string; playlistName?: string; albumName?: string; }> { if (track) { const body = new URLSearchParams({ q: `${track} ${artist ? artist : ""}`, type: "track", }); const res = await fetch( `https://api.spotify.com/v1/search?${body}`, { headers: this.headers, method: "GET", } ); const re = await res.json(); if (re.tracks.items.length > 0) { return { status: 200, trackId: re.tracks.items[0].uri, albumId: re.tracks.items[0].album.uri, artistName: re.tracks.items[0].artists[0].name, trackName: re.tracks.items[0].name, }; } } else if (artist) { const qp = new URLSearchParams({ q: `${artist}`, type: "artist", }); const res = await fetch(`https://api.spotify.com/v1/search?${qp}`, { headers: this.headers, }); const re = await res.json(); if (re.artists.items.length > 0) { return { status: 200, artistId: re.artists.items[0].uri, artistName: re.artists.items[0].name, }; } } else if (playlist) { const res = await fetch("https://api.spotify.com/v1/me/playlists", { headers: this.headers, }); const re = await res.json(); if (re.items.length > 0) { const match = re.items.find( (item: any) => item.name.toLowerCase() === playlist.toLowerCase() ); if (match) { return { status: 200, playlistId: match.uri, playlistName: match.name, }; } } } return { status: 400, }; } async pause() { await this.requiresLogin(); const res = await fetch("https://api.spotify.com/v1/me/player/pause", { method: "PUT", headers: this.headers, }); if (res.status == 204) { console.log(chalk.green("Paused playback!")); } else { console.log(chalk.red("Failed to pause playback!")); } process.exit(0); } async next() { await this.requiresLogin(); const res = await fetch("https://api.spotify.com/v1/me/player/next", { method: "POST", headers: this.headers, }); process.exit(0); } async previous() { await this.requiresLogin(); const res = await fetch( "https://api.spotify.com/v1/me/player/previous", { method: "POST", headers: this.headers, } ); process.exit(0); } async queue(track: string) { await this.requiresLogin(); const res = await this.search(track, undefined, undefined); if (res.status == 200) { const body = new URLSearchParams({ uri: res.trackId as string, }); const k = await fetch( `https://api.spotify.com/v1/me/player/queue?${body}`, { method: "POST", headers: this.headers, } ); if (k.status == 204) { console.log(chalk.green(`Queued ${res.trackName}!`)); } else { console.log(chalk.red("Failed to queue song!")); } } process.exit(0); } async repeat(input: "off" | "track" | "context") { await this.requiresLogin(); const body = new URLSearchParams({ state: input, }); const res = await fetch( `https://api.spotify.com/v1/me/player/repeat?${body}`, { method: "PUT", headers: this.headers, body: JSON.stringify(body), } ); if (res.status == 204) { console.log(chalk.green(`Set repeat to ${input}!`)); } else { console.log(chalk.red("Failed to set repeat!")); } process.exit(0); } async shuffle(input: boolean) { await this.requiresLogin(); const body = new URLSearchParams({ state: input ? "true" : "false", }); const res = await fetch( `https://api.spotify.com/v1/me/player/shuffle?${body}`, { method: "PUT", headers: this.headers, body: JSON.stringify(body), } ); if (res.status == 204) { console.log(chalk.green(`Set shuffle to ${input}!`)); } else { console.log(chalk.red("Failed to set shuffle!")); } process.exit(0); } async toggle() { await this.requiresLogin(); const res = await fetch("https://api.spotify.com/v1/me/player", { headers: this.headers, }); const re = await res.json(); if (re.is_playing) { await this.pause(); } else { await this.play(); } } async volume(input: string) { await this.requiresLogin(); const body = new URLSearchParams({ volume_percent: input, }); const res = await fetch( `https://api.spotify.com/v1/me/player/volume?${body}`, { method: "PUT", headers: this.headers, body: JSON.stringify(body), } ); if (res.status == 204) { console.log(chalk.green(`Set volume to ${input}!`)); } process.exit(0); } }