diff --git a/package-lock.json b/package-lock.json index 00c0e11..bd60fe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "express": "^4.18.2", "nanoid": "^3.3.6", "pkg": "^5.8.1", + "spotify-web-api-node": "^5.0.2", "yargs": "^17.7.2" }, "bin": { @@ -434,6 +435,11 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -648,6 +654,22 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -699,6 +721,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -755,6 +782,14 @@ "node": ">= 0.4" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -955,6 +990,11 @@ "node": ">=8.6.0" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -1018,6 +1058,28 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", + "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", + "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2143,6 +2205,14 @@ "node": ">=8" } }, + "node_modules/spotify-web-api-node": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/spotify-web-api-node/-/spotify-web-api-node-5.0.2.tgz", + "integrity": "sha512-r82dRWU9PMimHvHEzL0DwEJrzFk+SMCVfq249SLt3I7EFez7R+jeoKQd+M1//QcnjqlXPs2am4DFsGk8/GCsrA==", + "dependencies": { + "superagent": "^6.1.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2199,6 +2269,52 @@ "node": ">=0.10.0" } }, + "node_modules/superagent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz", + "integrity": "sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg==", + "deprecated": "Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at .", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.2", + "debug": "^4.1.1", + "fast-safe-stringify": "^2.0.7", + "form-data": "^3.0.0", + "formidable": "^1.2.2", + "methods": "^1.1.2", + "mime": "^2.4.6", + "qs": "^6.9.4", + "readable-stream": "^3.6.0", + "semver": "^7.3.2" + }, + "engines": { + "node": ">= 7.0.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index ee1458e..85d53ba 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "express": "^4.18.2", "nanoid": "^3.3.6", "pkg": "^5.8.1", + "spotify-web-api-node": "^5.0.2", "yargs": "^17.7.2" }, "devDependencies": { diff --git a/src/commands/authenticate.js b/src/commands/authenticate.js index 18a5211..97a7127 100644 --- a/src/commands/authenticate.js +++ b/src/commands/authenticate.js @@ -3,31 +3,36 @@ const {input} = require("@inquirer/prompts"); const querystring = require("querystring"); const {nanoid} = require("nanoid"); const express = require('express') -const {setAuth, hasAuth, authFile} = require("../utilities/authentication"); +const {spotify, spotifyCredentialsFile, hasSpotifyCredentials, saveSpotifyCredentials} = require("../utilities/spotify"); exports.command = 'authenticate' exports.description = 'starts the Spotity authentication process' -exports.builder = function (argv) { - return argv.option('client-id') -} -; +exports.builder = (argv) => argv + .option('client-id', { + description: 'The Spotify app client id', + type: 'string' + }) + .option('client-secret', { + description: 'The Spotify app client secret', + type: 'string' + }) exports.handler = async function (argv) { - if (hasAuth()) { - console.log(`spotify.local is already authenticated, remove "${authFile}" is you want to log out.`); + if (hasSpotifyCredentials()) { + console.log(`spotify.local is already authenticated, remove "${spotifyCredentialsFile}" is you want to log out.`); return; } const port = await findAvailablePort(); const authenticateRoute = 'authenticate'; - const authenticateUrl = `http://localhost:${port}/${authenticateRoute}`; + const redirectUri = `http://localhost:${port}/${authenticateRoute}`; const tutorial = [ 'Visit https://developer.spotify.com/dashboard and click "Create app"', 'Set the "App name" as "spotify.local"', 'Set the "App description" as "My spotify.local"', 'Set the "Website" as "https://code.tmp.dev/ian/spotify.local"', - `Set the "Redirect URI" as "${authenticateUrl}"`, + `Set the "Redirect URI" as "${redirectUri}"`, 'Select under "Which API/SDKs are you planning to use?" the "Web API"', 'Sign your life away by agreeing with the "Developer Terms of Service and Design Guidelines"', 'Click on the "Save" button', @@ -36,12 +41,18 @@ exports.handler = async function (argv) { tutorial.forEach((string, index) => console.log(`${index+1} ${string}`)); - const clientId = argv.clientId ?? await input({ message: `${tutorial.length}. Paste the clientId here` }) + const clientId = argv.clientId ?? await input({ message: `${tutorial.length}. Paste the client id here` }) + const clientSecret = argv.clientSecret ?? await input({ message: `${tutorial.length}. Paste the client secret here` }) const originalState = nanoid(); - const redirectUrl = new URL('https://accounts.spotify.com/authorize'); - redirectUrl.search = querystring.stringify({ + + spotify.setClientId(clientId); + spotify.setClientSecret(clientSecret); + spotify.setRedirectURI(redirectUri); + + const authUrl = new URL('https://accounts.spotify.com/authorize'); + authUrl.search = querystring.stringify({ response_type: 'code', - client_id: clientId, + client_id: spotify.getClientId(), scope: [ 'user-library-read', 'user-read-private', @@ -59,28 +70,35 @@ exports.handler = async function (argv) { 'user-read-currently-playing', 'user-read-playback-position' ].join(' '), - redirect_uri: authenticateUrl, + redirect_uri: spotify.getRedirectURI(), state: originalState }); const app = express(); let server; - app.get(`/${authenticateRoute}`, (req, res) => { - const {state, code} = req.query; - if (originalState === state) { - setAuth(code); + app.get(`/${authenticateRoute}`, async (req, res) => { + const {state, code, error = null} = req.query; + if (!error && originalState === state) { + const { body } = await spotify.authorizationCodeGrant(code) + const {access_token, refresh_token, expires_in} = body; + spotify.setExpired((new Date).getTime() + expires_in); + spotify.setAccessToken(access_token); + spotify.setRefreshToken(refresh_token); + saveSpotifyCredentials(); res.send(''); server?.close(); console.log(`${tutorial.length+2}. Spotify.local has been authenticated`); - } else { - res.status(400); - res.send('NO') + + return; } + + res.status(400); + res.send('NO'); }); - server = app.listen(port, () => console.log(`${tutorial.length+1}. Follow the following URL to authenticate: ${redirectUrl}`)); + server = app.listen(port, () => console.log(`${tutorial.length+1}. Follow the following URL to authenticate: ${authUrl}`)); } \ No newline at end of file diff --git a/src/commands/next.js b/src/commands/next.js new file mode 100644 index 0000000..9186347 --- /dev/null +++ b/src/commands/next.js @@ -0,0 +1,8 @@ +const { spotify} = require('../utilities/spotify') + +exports.command = 'next' +exports.handler = async () => { + spotify.ensure() + .then(spotify => spotify.skipToNext()) + .catch(e => console.error(e.message)) +}; \ No newline at end of file diff --git a/src/utilities/authentication.js b/src/utilities/authentication.js deleted file mode 100644 index 52e0a23..0000000 --- a/src/utilities/authentication.js +++ /dev/null @@ -1,16 +0,0 @@ -const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('fs'); -const { homedir } = require('os'); - -const configDir = `${homedir()}/.spotify-local` -const authenticationFile = `${configDir}/authentication` - -const ensure = () => { - if (!existsSync(configDir)) mkdirSync(configDir); - if (!existsSync(authenticationFile)) writeFileSync(authenticationFile, ''); - return true; -}; - -exports.authFile = authenticationFile; -exports.setAuth = (code) => ensure() && writeFileSync(authenticationFile, code); -exports.getAuth = () => ensure() && readFileSync(authenticationFile).toString(); -exports.hasAuth = () => ensure() && readFileSync(authenticationFile).toString() !== ''; \ No newline at end of file diff --git a/src/utilities/spotify.js b/src/utilities/spotify.js new file mode 100644 index 0000000..509ee9a --- /dev/null +++ b/src/utilities/spotify.js @@ -0,0 +1,56 @@ +const SpotifyWebApi = require('spotify-web-api-node'); +const {homedir} = require("os"); +const {existsSync, mkdirSync, writeFileSync, readFileSync} = require("fs"); + +const configDir = `${homedir()}/.spotify-local` +const spotifyCredentialsFile = `${configDir}/credentials.json` + +const ensure = () => { + if (!existsSync(configDir)) mkdirSync(configDir); + if (!existsSync(spotifyCredentialsFile)) writeFileSync(spotifyCredentialsFile, '{}'); + return true; +}; + +SpotifyWebApi.prototype.setExpired = function (expiresIn) { + const expired = (new Date).getTime() + expiresIn; + + this._setCredential('expired', expired); +} + +SpotifyWebApi.prototype.getExpired = function () { + return this._getCredential('expired') ?? 0; +} + +/** + * @returns {Promise} + */ +SpotifyWebApi.prototype.ensure = async function () { + const timeExpired = this.getExpired(); + const time = (new Date).getTime(); + if (time > timeExpired) { + const { body } = await this.refreshAccessToken(); + const { access_token, expires_in } = body; + this.setAccessToken(access_token); + this.setExpired((new Date).getTime() + expires_in); + saveSpotifyCredentials(); + } + + return this; +} + +const spotify = new SpotifyWebApi(); + +const saveSpotifyCredentials = () => ensure() && writeFileSync(spotifyCredentialsFile, JSON.stringify(spotify.getCredentials())); +const restoreSpotifyCredentials = () => ensure() && spotify.setCredentials(JSON.parse(readFileSync(spotifyCredentialsFile).toString())); +const hasSpotifyCredentials = () => existsSync(spotifyCredentialsFile); + +if (existsSync(spotifyCredentialsFile)) restoreSpotifyCredentials(); + + +module.exports = { + spotify, + spotifyCredentialsFile, + saveSpotifyCredentials, + restoreSpotifyCredentials, + hasSpotifyCredentials, +}